judge_rails 0.0.2 → 0.0.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1164267a3bd68794b33111be3918b6149bcd0f2f0b23755359eb8b79e677bece
4
- data.tar.gz: 0b3dd424c618db92f4f1b82e216b1f9f894eabcf59a22e97c7570503ff4d46a9
3
+ metadata.gz: 4cd6199504db2095cd4dfc279ad386bd9effb10d5ab40719c8fe9cd8f0850822
4
+ data.tar.gz: 185fe243d146c57ee4d30c724b7104fe0f4624572467701ea25089a554508e27
5
5
  SHA512:
6
- metadata.gz: c7f8955174b2a7983e47b171c5e601f3071c5bd5aa314c1ce2f42f936871f9b0c16669e542c7789faeca089f52a0345e1f3205ad8960ed2d35c81310605350ef
7
- data.tar.gz: 5376194fbc848b03cac396fdac770d02c849acf0802fcbae9a8cecae716b2e43f7ea12427cc3d300ede0295f6f03aff531612a02accfd9ecd805990508f80e3f
6
+ metadata.gz: 0640bbb3751542608ff2d22863af16e5a1132d7b9247a2595149d82b30631ea6bc09e6e89ea75858eebdb25cbf6fdeb1cf8636a7d606bf0c6baace00588c9be9
7
+ data.tar.gz: fd33919299343282420c12f643651b8215ed3ee0135d0fb027dfb89dc5bfef8dedcbcec9c30239671fc257f2b683d83b83064edecb9bb7408862403e01904124
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.3 (2026-10-04)
4
+
5
+ ### Added
6
+
7
+ - **Cloudflare Clef.** A built-in `:clef` adapter sends the same questions to Clef and Clef-flash on
8
+ Workers AI. It reuses the Jev client, so retries, pooling, errors and instrumentation behave the same.
9
+ `JUDGE_ADAPTER=clef` with `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` is the whole setup.
10
+ - `config.cloudflare_api_token` and `config.cloudflare_account_id`, read from those two variables and
11
+ kept apart from `api_key`, so Jev and Clef can live in one app. `inspect` hides the token.
12
+ - `adapter:` on `Judge.ask` and the ActiveRecord helpers now also takes a registered name, such as
13
+ `adapter: :clef`, and not only an adapter object.
14
+
15
+ ### Changed
16
+
17
+ - With no explicit `config.model` or `JEV_MODEL`, the model follows the adapter: `clef` under `:clef`,
18
+ `jev-latest` otherwise. Nothing changes for Jev users. Moving an attribute to Clef changes its model,
19
+ so its stored judgments go stale and are recomputed.
20
+
3
21
  ## 0.0.2 (2026-09-26)
4
22
 
5
23
  No change to the gem itself. The first release published by `release.yml` through RubyGems trusted
data/README.md CHANGED
@@ -469,7 +469,7 @@ The event carries sizes and counts. It never carries the state or the key.
469
469
  ```ruby
470
470
  Judge.configure do |config|
471
471
  config.api_key = "..." # read from JEV_API_KEY or TYPESAFE_API_KEY by default
472
- config.model = "jev-latest"
472
+ config.model = "jev-latest" # "clef" when the adapter is :clef
473
473
  config.timeout = 10.0
474
474
  config.open_timeout = 5.0
475
475
  config.max_retries = 2
@@ -543,6 +543,45 @@ The default is `:jev` and stays `:jev` until you change it. `JUDGE_ADAPTER` pick
543
543
  environment, and `adapter:` overrides it for a single call, which is how the test suite installs a
544
544
  recorder.
545
545
 
546
+ ### Cloudflare Clef
547
+
548
+ `:clef` ships with the gem. It sends the same questions to [Clef](https://developers.cloudflare.com/workers-ai/models/clef/)
549
+ on Cloudflare Workers AI and needs a Workers AI API token and your account id:
550
+
551
+ ```sh
552
+ JUDGE_ADAPTER=clef
553
+ CLOUDFLARE_API_TOKEN=...
554
+ CLOUDFLARE_ACCOUNT_ID=...
555
+ ```
556
+
557
+ ```ruby
558
+ Judge.configure do |config|
559
+ config.adapter = :clef # or JUDGE_ADAPTER=clef
560
+ config.cloudflare_api_token = "..." # read from CLOUDFLARE_API_TOKEN by default
561
+ config.cloudflare_account_id = "..." # read from CLOUDFLARE_ACCOUNT_ID by default
562
+ end
563
+
564
+ Judge.ask(Judge.noul("Is this urgent?"), text: "Checkout is down for everyone").probability # => 0.98
565
+ ```
566
+
567
+ The model follows the adapter: `clef` under `:clef`, `jev-latest` otherwise. `clef-flash` is the smaller,
568
+ faster one. Set it with `config.model`, `JEV_MODEL`, or a pin on one attribute:
569
+
570
+ ```ruby
571
+ judge_attribute :urgency, Judge.noul("..."), model: "clef-flash"
572
+ ```
573
+
574
+ Clef only serves those two names. Asking it for any other model raises `Judge::ConfigurationError` before
575
+ a request leaves. `api_key` stays the Jev key, so one app can keep `:jev` and try Clef on a single call,
576
+ naming the model since the configured one is still Jev's:
577
+
578
+ ```ruby
579
+ Judge.ask(question, text: text, adapter: :clef, model: "clef")
580
+ ```
581
+
582
+ Switching an attribute from Jev to Clef changes its model, so its stored judgments go stale and are
583
+ recomputed on the next refresh, the same as changing a pin.
584
+
546
585
  ### You may not need an adapter at all
547
586
 
548
587
  The portable thing is the protocol, not this registry. Anything already serving this shape works
@@ -7,6 +7,13 @@ Judge.configure do |config|
7
7
 
8
8
  config.logger = Rails.logger
9
9
 
10
+ # Cloudflare Clef instead of Jev: set JUDGE_ADAPTER=clef, CLOUDFLARE_API_TOKEN and
11
+ # CLOUDFLARE_ACCOUNT_ID, or:
12
+ #
13
+ # config.adapter = :clef
14
+ # config.cloudflare_api_token = Rails.application.credentials.dig(:cloudflare, :api_token)
15
+ # config.cloudflare_account_id = Rails.application.credentials.dig(:cloudflare, :account_id)
16
+
10
17
  # Defaults, uncomment to override:
11
18
  #
12
19
  # config.base_url = "https://api.typesafe.ai/v1/systemone"
data/lib/judge/adapter.rb CHANGED
@@ -8,7 +8,7 @@ module Judge
8
8
 
9
9
  class << self
10
10
  def registry
11
- @registry ||= { jev: -> { Client.new } }
11
+ @registry ||= { jev: -> { Client.new }, clef: -> { Clef.new } }
12
12
  end
13
13
 
14
14
  def register(name, &build)
data/lib/judge/clef.rb ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "judge/client"
4
+
5
+ module Judge
6
+ # Cloudflare's Clef on Workers AI. Same request and answers as Jev, behind a Cloudflare envelope
7
+ # and an account-scoped URL.
8
+ class Clef < Client
9
+ MODELS = %w[clef clef-flash].freeze
10
+ API_ROOT = "https://api.cloudflare.com/client/v4"
11
+
12
+ def initialize(config: nil, sleeper: nil, api_root: API_ROOT)
13
+ super(config: config, sleeper: sleeper)
14
+ @api_root = api_root.to_s.chomp("/")
15
+ end
16
+
17
+ def inspect
18
+ "#<Judge::Clef model=#{@config.model.inspect}>"
19
+ end
20
+
21
+ private
22
+
23
+ def endpoint(model)
24
+ unless MODELS.include?(model)
25
+ raise ConfigurationError, "Clef serves #{MODELS.join(" and ")}, not #{model.inspect}. " \
26
+ "Set config.model or pass model: \"clef\""
27
+ end
28
+
29
+ URI.parse("#{@api_root}/accounts/#{@config.cloudflare_account_id!}/ai/run/@cf/cloudflare/#{model}")
30
+ end
31
+
32
+ def authorization
33
+ "Bearer #{@config.cloudflare_api_token!}"
34
+ end
35
+
36
+ def unwrap(body)
37
+ result = body["result"]
38
+ return result if body["success"] != false && result.is_a?(Hash)
39
+
40
+ raise InvalidResponseError, "Cloudflare returned no result: #{error_message(JSON.generate(body))}"
41
+ end
42
+
43
+ def error_message(body)
44
+ parsed = JSON.parse(body)
45
+ errors = parsed["errors"] if parsed.is_a?(Hash)
46
+ message = errors.first["message"] if errors.is_a?(Array) && errors.first.is_a?(Hash)
47
+ message ? message.to_s : super
48
+ rescue JSON::ParserError
49
+ super
50
+ end
51
+ end
52
+ end
data/lib/judge/client.rb CHANGED
@@ -44,13 +44,14 @@ module Judge
44
44
  raise ArgumentError, "state must not be nil" if state.nil?
45
45
  raise ArgumentError, "questions must not be empty" if questions.nil? || questions.empty?
46
46
 
47
+ model ||= @config.model
48
+ target = endpoint(model)
47
49
  payload = JSON.generate(build_payload(state, questions, model))
48
- event = { model: model || @config.model, questions: questions.size,
49
- request_bytes: payload.bytesize }
50
+ event = { model: model, questions: questions.size, request_bytes: payload.bytesize }
50
51
 
51
52
  instrument(event) do
52
53
  started = monotonic
53
- body = perform(payload)
54
+ body = unwrap(perform(target, payload))
54
55
  set = ResultSet.from_response(body, questions: questions, latency: monotonic - started)
55
56
  event[:latency] = set.latency
56
57
  event[:input_tokens] = set.usage&.input_tokens
@@ -70,18 +71,33 @@ module Judge
70
71
  def build_payload(state, questions, model)
71
72
  {
72
73
  "state" => state,
73
- "model" => model || @config.model,
74
+ "model" => model,
74
75
  "questions" => questions.to_h { |name, question| [name.to_s, question.to_payload] }
75
76
  }
76
77
  end
77
78
 
78
- def perform(payload)
79
- authorization = "Bearer #{@config.api_key!}"
79
+ def endpoint(_model)
80
+ url = @config.base_url
81
+ @uri = URI.parse(url) if @uri_source != url
82
+ @uri_source = url
83
+ @uri
84
+ end
85
+
86
+ def authorization
87
+ "Bearer #{@config.api_key!}"
88
+ end
89
+
90
+ def unwrap(body)
91
+ body
92
+ end
93
+
94
+ def perform(target, payload)
95
+ authorization = self.authorization
80
96
  attempt = 0
81
97
 
82
98
  loop do
83
99
  attempt += 1
84
- response = attempt_request(authorization, payload, attempt)
100
+ response = attempt_request(target, authorization, payload, attempt)
85
101
  next if response.nil?
86
102
 
87
103
  status = response.code.to_i
@@ -100,14 +116,14 @@ module Judge
100
116
  after || backoff(attempt)
101
117
  end
102
118
 
103
- def attempt_request(authorization, payload, attempt)
119
+ def attempt_request(target, authorization, payload, attempt)
104
120
  started = monotonic
105
- response = execute(authorization, payload)
106
- log(response.code, monotonic - started, attempt)
121
+ response = execute(target, authorization, payload)
122
+ log(target, response.code, monotonic - started, attempt)
107
123
  response
108
124
  rescue *TRANSPORT_ERRORS => e
109
- close_connection
110
- log(e.class.name, monotonic - started, attempt)
125
+ close_connection(target)
126
+ log(target, e.class.name, monotonic - started, attempt)
111
127
  if attempt > @config.max_retries || UNSAFE_TO_RESEND.any? { |klass| e.is_a?(klass) }
112
128
  raise TransportError, "#{e.class}: #{e.message}"
113
129
  end
@@ -116,39 +132,31 @@ module Judge
116
132
  nil
117
133
  end
118
134
 
119
- def execute(authorization, payload)
120
- request = Net::HTTP::Post.new(uri.request_uri)
135
+ def execute(target, authorization, payload)
136
+ request = Net::HTTP::Post.new(target.request_uri)
121
137
  request["Authorization"] = authorization
122
138
  request["Content-Type"] = "application/json"
123
139
  request["Accept"] = "application/json"
124
140
  request.body = payload
125
- connection.request(request)
126
- end
127
-
128
- def uri
129
- url = @config.base_url
130
- @uri = URI.parse(url) if @uri_source != url
131
- @uri_source = url
132
- @uri
141
+ connection(target).request(request)
133
142
  end
134
143
 
135
- def connection_key
136
- [@config.base_url, @config.open_timeout, @config.timeout].freeze
144
+ def connection_key(target)
145
+ [target.to_s, @config.open_timeout, @config.timeout].freeze
137
146
  end
138
147
 
139
- def connection
148
+ def connection(target)
140
149
  store = connections
141
- key = connection_key
150
+ key = connection_key(target)
142
151
  http = store[key]
143
152
  return http if http&.started?
144
153
 
145
- store[key] = start_connection(key.first)
154
+ store[key] = start_connection(target)
146
155
  end
147
156
 
148
- def start_connection(base_url)
149
- uri = URI.parse(base_url)
150
- http = Net::HTTP.new(uri.host, uri.port)
151
- http.use_ssl = uri.scheme == "https"
157
+ def start_connection(target)
158
+ http = Net::HTTP.new(target.host, target.port)
159
+ http.use_ssl = target.scheme == "https"
152
160
  http.open_timeout = @config.open_timeout
153
161
  http.read_timeout = @config.timeout
154
162
  http.write_timeout = @config.timeout if http.respond_to?(:write_timeout=)
@@ -166,8 +174,8 @@ module Judge
166
174
  store[:connections]
167
175
  end
168
176
 
169
- def close_connection
170
- http = connections.delete(connection_key)
177
+ def close_connection(target)
178
+ http = connections.delete(connection_key(target))
171
179
  http.finish if http&.started?
172
180
  rescue IOError
173
181
  nil
@@ -235,12 +243,12 @@ module Judge
235
243
  body.length > limit ? "#{body[0, limit]}..." : body
236
244
  end
237
245
 
238
- def log(status, latency, attempt)
246
+ def log(target, status, latency, attempt)
239
247
  logger = @config.logger
240
248
  return unless logger
241
249
 
242
250
  logger.debug do
243
- "Judge POST #{uri.path} status=#{status} latency=#{latency.round(3)}s attempt=#{attempt}"
251
+ "Judge POST #{target.path} status=#{status} latency=#{latency.round(3)}s attempt=#{attempt}"
244
252
  end
245
253
  end
246
254
 
@@ -4,16 +4,19 @@ module Judge
4
4
  class Configuration
5
5
  DEFAULT_BASE_URL = "https://api.typesafe.ai/v1/systemone"
6
6
  DEFAULT_MODEL = "jev-latest"
7
+ CLEF_DEFAULT_MODEL = "clef"
7
8
 
8
- attr_reader :api_key, :base_url, :model, :timeout, :open_timeout, :max_retries, :max_retry_wait,
9
- :concurrency, :adapter
9
+ attr_reader :api_key, :base_url, :timeout, :open_timeout, :max_retries, :max_retry_wait,
10
+ :concurrency, :adapter, :cloudflare_api_token, :cloudflare_account_id
10
11
  attr_accessor :logger
11
12
 
12
13
  def initialize
13
14
  self.api_key = [ENV.fetch("JEV_API_KEY", nil), ENV.fetch("TYPESAFE_API_KEY", nil)]
14
15
  .find { |value| !blank?(value) }
15
16
  self.base_url = present_env("JEV_BASE_URL") || DEFAULT_BASE_URL
16
- self.model = present_env("JEV_MODEL") || DEFAULT_MODEL
17
+ @model = present_env("JEV_MODEL")
18
+ self.cloudflare_api_token = ENV.fetch("CLOUDFLARE_API_TOKEN", nil)
19
+ self.cloudflare_account_id = ENV.fetch("CLOUDFLARE_ACCOUNT_ID", nil)
17
20
  self.timeout = 10.0
18
21
  self.open_timeout = 5.0
19
22
  self.max_retries = 2
@@ -33,6 +36,18 @@ module Judge
33
36
  @base_url = value.to_s.strip
34
37
  end
35
38
 
39
+ def cloudflare_api_token=(value)
40
+ @cloudflare_api_token = blank?(value) ? nil : value.to_s.strip
41
+ end
42
+
43
+ def cloudflare_account_id=(value)
44
+ @cloudflare_account_id = blank?(value) ? nil : value.to_s.strip
45
+ end
46
+
47
+ def model
48
+ @model || (@adapter == :clef ? CLEF_DEFAULT_MODEL : DEFAULT_MODEL)
49
+ end
50
+
36
51
  def model=(value)
37
52
  raise ArgumentError, "model must not be blank" if blank?(value)
38
53
 
@@ -77,10 +92,25 @@ module Judge
77
92
  raise ConfigurationError, "No Judge API key. Set JEV_API_KEY or Judge.configure { |c| c.api_key = ... }"
78
93
  end
79
94
 
95
+ def cloudflare_api_token!
96
+ return @cloudflare_api_token if @cloudflare_api_token
97
+
98
+ raise ConfigurationError, "No Cloudflare API token. Set CLOUDFLARE_API_TOKEN or " \
99
+ "Judge.configure { |c| c.cloudflare_api_token = ... }"
100
+ end
101
+
102
+ def cloudflare_account_id!
103
+ return @cloudflare_account_id if @cloudflare_account_id
104
+
105
+ raise ConfigurationError, "No Cloudflare account id. Set CLOUDFLARE_ACCOUNT_ID or " \
106
+ "Judge.configure { |c| c.cloudflare_account_id = ... }"
107
+ end
108
+
80
109
  def inspect
81
110
  key = @api_key ? "[FILTERED]" : "nil"
82
- "#<Judge::Configuration api_key=#{key} base_url=#{@base_url.inspect} model=#{@model.inspect} " \
83
- "adapter=#{@adapter.inspect}>"
111
+ token = @cloudflare_api_token ? "[FILTERED]" : "nil"
112
+ "#<Judge::Configuration api_key=#{key} cloudflare_api_token=#{token} base_url=#{@base_url.inspect} " \
113
+ "model=#{model.inspect} adapter=#{@adapter.inspect}>"
84
114
  end
85
115
 
86
116
  private
data/lib/judge/facade.rb CHANGED
@@ -21,7 +21,8 @@ module Judge
21
21
  normalized = normalize_questions(questions)
22
22
  raise ArgumentError, "ask needs at least one question" if normalized.empty?
23
23
 
24
- results = (adapter || self.adapter).call(state: text, questions: normalized, model: model)
24
+ provider = adapter ? Adapter.build(adapter) : self.adapter
25
+ results = provider.call(state: text, questions: normalized, model: model)
25
26
  return results unless single
26
27
 
27
28
  name = normalized.keys.first
data/lib/judge/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Judge
4
- VERSION = "0.0.2"
4
+ VERSION = "0.0.3"
5
5
  end
data/lib/judge.rb CHANGED
@@ -18,6 +18,7 @@ module Judge
18
18
  extend Facade
19
19
 
20
20
  autoload :Client, "judge/client"
21
+ autoload :Clef, "judge/clef"
21
22
 
22
23
  @config_lock = Mutex.new
23
24
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: judge_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hugo V
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-26 00:00:00.000000000 Z
11
+ date: 2026-10-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -69,6 +69,7 @@ files:
69
69
  - lib/generators/judge/templates/migration.rb.tt
70
70
  - lib/judge.rb
71
71
  - lib/judge/adapter.rb
72
+ - lib/judge/clef.rb
72
73
  - lib/judge/client.rb
73
74
  - lib/judge/configuration.rb
74
75
  - lib/judge/decision.rb