omniai-evals 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de97e262c2ca4119e0f158d0b4ed485ffeb9af5711edb529d099685fe277d615
4
- data.tar.gz: ce16c93c7acab7e7f6c57680b65a14f04073ad21c7ad9f632efd746b60fb32a6
3
+ metadata.gz: 67cfea27bec341f1486a603f0cf4d3e77e31f0c5980ec819da55bef1e1b64a4e
4
+ data.tar.gz: 0b2c522085dd143abd823f2e0491629bc980c4882104379b3d9450ea353afa47
5
5
  SHA512:
6
- metadata.gz: df80b33102d5c4e691f143136de334d35a9e3755e6bde89d71147de1feba1c2a334f1091c69995fb3945fd6b8740caae68a3d1e5d9f2d52805ee5ba8fb6a39a0
7
- data.tar.gz: 2cfaaab87db9222a769db038b8b6d3800f570d3b351fd625e522f23157c9f5b21ef24b1fa7f5894516aeff8459404397d63b471ede0652aeaf555861e60afcca
6
+ metadata.gz: 485cb1d953a7da41ccc5e8a665e66ed6a73ce5f76ba2445c6c374a783a2f906d5ff7530b787a20037b66b66cedadd27ce0977bdcbaf7fec741edf68fb7ea83cc
7
+ data.tar.gz: bd7ff67a7fd6feeebf086258d5a97ee6269445389a9611a2b656db1b32b23bd457afaa6545919f79634689f0b46a1107e83bda38ee88b7938f642a65169fbcd3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - `Adapters::ClaudeCLI` retries a rate limit: a process whose envelope has `api_error_status` 429 or 529 runs again, up to 5 processes, waiting 30s doubling each try plus up to 30s of jitter. `attempts` in the call's one record counts processes.
6
+ - Out of attempts, the adapter's limit is spent: the call raises `ClaudeCLI::RateLimitSpent` (a `JudgeError`), no new call starts, and calls still waiting stop. A spent adapter stays spent, so a new run uses a new adapter. The gem never exits; the caller decides, using the error or `adapter.rate_limit.spent?`.
7
+
3
8
  ## 0.1.0
4
9
 
5
10
  Initial release.
data/README.md CHANGED
@@ -119,6 +119,10 @@ bundle exec rake # rspec + rubocop
119
119
 
120
120
  The six contract invariants have their own spec file, `spec/omniai/evals/invariants_spec.rb`.
121
121
 
122
+ ## Releasing
123
+
124
+ Bump `VERSION` and `CHANGELOG.md` and merge. Dean then tags that commit on main `vX.Y.Z` and pushes the tag. The Release workflow runs the suite, checks the tag matches `VERSION` and is on main, then waits for Dean to approve the `release` environment before pushing the gem via RubyGems trusted publishing.
125
+
122
126
  ## License
123
127
 
124
128
  MIT. See `LICENSE`.
@@ -8,24 +8,52 @@ module OmniAI
8
8
  # One per-call record for an adapter's optional `calls:` collector: stop codes and numbers
9
9
  # only, never result text or stderr (both can carry PHI).
10
10
  #
11
- # `attempts` is always 1: neither adapter retries, and retries inside `claude -p` or the
12
- # provider client are not reported.
11
+ # `attempts` counts `claude -p` processes: one more each time a process came back rate limited
12
+ # (see RateLimit). `latency_ms` covers them and their waits. Retries inside `claude -p` or the
13
+ # provider client are not reported, so Adapters::OmniAI always records 1.
13
14
  module CallRecord
14
15
  DURATION_KEYS = %w[duration_ms duration_api_ms].freeze
15
16
  # A call that finished but named no reason.
16
17
  UNREPORTED = "unreported"
17
18
 
18
- # For Adapters::ClaudeCLI. The block receives a sink to hand the subprocess stdout to
19
- # (including on a non-zero exit, so an error envelope's subtype is still recorded) and
20
- # returns that stdout. The stdout stays here; it is parsed only for stop codes and numbers.
21
- def self.capture(calls, timeout_error:, &block)
22
- return yield(nil) if calls.nil?
19
+ # For Adapters::ClaudeCLI. Runs the block once per process, again while +rate_limit+ says to,
20
+ # and pushes exactly one record however that ends, from the last process to finish. The block
21
+ # receives a sink to hand the subprocess stdout to (including on a non-zero exit, so an error
22
+ # envelope's subtype is still recorded) and returns that stdout. Its errors are kept for the
23
+ # caller to raise; only what isn't a StandardError, like an Interrupt, goes straight up.
24
+ #
25
+ # @api private
26
+ # @return [Hash] the last process: :stdout, and :result or :error
27
+ def self.capture(calls, timeout_error:, rate_limit:, &process)
28
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
+ attempts = 1
30
+ run = nil
31
+ begin
32
+ run = attempt(timeout_error, &process)
33
+ while rate_limit.run_again?(run[:stdout], attempts)
34
+ attempts += 1
35
+ run = attempt(timeout_error, &process)
36
+ end
37
+ run
38
+ ensure
39
+ latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
40
+ failure = run ? run[:failure] : "error"
41
+ calls&.<<(envelope_record(run && run[:stdout], failure:, latency_ms:, attempts:))
42
+ end
43
+ end
23
44
 
45
+ # One process: what it printed, and how it failed. The stdout is parsed only for stop codes,
46
+ # numbers and the rate limit.
47
+ #
48
+ # @api private
49
+ def self.attempt(timeout_error)
24
50
  stdout = nil
25
- sink = ->(out) { stdout = out }
26
- measure(calls, timeout_error:, body: -> { block.call(sink) }) do |failure, latency_ms|
27
- envelope_record(stdout, failure:, latency_ms:)
28
- end
51
+ result = yield(->(out) { stdout = out })
52
+ { stdout: stdout || result, result: }
53
+ rescue timeout_error => e
54
+ { stdout:, failure: "timeout", error: e }
55
+ rescue StandardError => e
56
+ { stdout:, failure: "error", error: e }
29
57
  end
30
58
 
31
59
  # For Adapters::OmniAI. The block returns an OmniAI::Chat::Response; its verbatim provider
@@ -61,18 +89,18 @@ module OmniAI
61
89
  # "unreported". A failed call keeps +failure+ unless its stdout is an is_error envelope, so
62
90
  # a timeout, or a non-zero exit without one, records "timeout" / "error"; stdout that is
63
91
  # not a JSON object records "error".
64
- def self.envelope_record(stdout, failure:, latency_ms:)
92
+ def self.envelope_record(stdout, failure:, latency_ms:, attempts: 1)
65
93
  envelope = envelope(stdout)
66
- return record(failure || "error", latency_ms) if envelope.nil?
67
- return record(failure, latency_ms) if failure && !envelope["is_error"]
94
+ return record(failure || "error", latency_ms, attempts) if envelope.nil?
95
+ return record(failure, latency_ms, attempts) if failure && !envelope["is_error"]
68
96
 
69
- result = record(finish_reason(envelope), latency_ms)
97
+ result = record(finish_reason(envelope), latency_ms, attempts)
70
98
  DURATION_KEYS.each { |key| result[key] = envelope[key] if envelope[key].is_a?(Numeric) }
71
99
  result
72
100
  end
73
101
 
74
- def self.record(finish_reason, latency_ms)
75
- { "finish_reason" => finish_reason, "latency_ms" => latency_ms, "attempts" => 1 }
102
+ def self.record(finish_reason, latency_ms, attempts = 1)
103
+ { "finish_reason" => finish_reason, "latency_ms" => latency_ms, "attempts" => attempts }
76
104
  end
77
105
 
78
106
  # Strings only, so an odd envelope can't put a nested object into the record.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OmniAI
6
+ module Evals
7
+ module Adapters
8
+ class ClaudeCLI
9
+ # Reads the verdict out of a `claude -p` process's stdout. Error messages stay PHI-free: byte
10
+ # counts only, never the output.
11
+ module Verdict
12
+ # Parses the `claude -p --output-format json` envelope ({ is_error, result })
13
+ # and returns the verdict Hash carried in `result`. The error message never
14
+ # includes the raw output -- it holds PHI (verbatim source quotes).
15
+ def self.parse_output(stdout)
16
+ envelope = JSON.parse(stdout)
17
+ raise JudgeError, "claude reported an error result" if envelope["is_error"]
18
+
19
+ parse_verdict(envelope.fetch("result", "").to_s)
20
+ rescue JSON::ParserError
21
+ raise JudgeError, "unparseable judge output (#{stdout.bytesize} bytes)"
22
+ end
23
+
24
+ # The judge is told to emit bare JSON, but LLM output is nondeterministic -- the
25
+ # same input can come back ```-fenced one run and wrapped in prose the next.
26
+ # Strip fences first; if that still isn't valid JSON, fall back to the outermost
27
+ # { ... } span. Raises (PHI-free) only when no JSON object is recoverable.
28
+ def self.parse_verdict(result)
29
+ cleaned = result.strip.gsub(/\A```(?:json)?\s*/m, "").gsub(/\s*```\z/m, "").strip
30
+ JSON.parse(cleaned)
31
+ rescue JSON::ParserError
32
+ span = cleaned[/\{.*\}/m]
33
+ raise JudgeError, "unparseable judge output (#{result.bytesize} bytes)" if span.nil?
34
+
35
+ # A non-nil span can still be invalid JSON (e.g. prose braces). Rescue here too
36
+ # so it surfaces as a PHI-free JudgeError, never a raw JSON::ParserError leaking
37
+ # the (possibly PHI-bearing) content.
38
+ begin
39
+ JSON.parse(span)
40
+ rescue JSON::ParserError
41
+ raise JudgeError, "unparseable judge output (#{result.bytesize} bytes)"
42
+ end
43
+ end
44
+
45
+ # Reads the schema-constrained object from a `claude -p --json-schema` envelope's
46
+ # `structured_output` field (constrained decoding populates it). The free-form
47
+ # `result` still holds the model's prose transcription -- its chain-of-thought --
48
+ # but the deterministic artifact is `structured_output`. An absent field means the
49
+ # model transcribed but never emitted the constrained object (refusal / max_tokens):
50
+ # a COUNTED JudgeError, never a silent nil. Error messages stay PHI-free (byte counts
51
+ # only) -- the envelope can echo verbatim source quotes.
52
+ def self.parse_structured(stdout)
53
+ envelope = JSON.parse(stdout)
54
+ raise JudgeError, "claude reported an error result" if envelope["is_error"]
55
+
56
+ verdict = envelope["structured_output"]
57
+ raise JudgeError, "no structured_output in judge response (#{stdout.bytesize} bytes)" unless
58
+ verdict.is_a?(Hash)
59
+
60
+ verdict
61
+ rescue JSON::ParserError
62
+ raise JudgeError, "unparseable judge output (#{stdout.bytesize} bytes)"
63
+ end
64
+
65
+ private_class_method :parse_verdict
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -5,6 +5,8 @@ require "open3"
5
5
 
6
6
  require_relative "../../evals"
7
7
  require_relative "call_record"
8
+ require_relative "rate_limit"
9
+ require_relative "claude_cli/verdict"
8
10
 
9
11
  module OmniAI
10
12
  module Evals
@@ -41,6 +43,12 @@ module OmniAI
41
43
  class TimeoutError < JudgeError
42
44
  end
43
45
 
46
+ # A rate limit outlasted its attempts, so this adapter starts no new call. It is a JudgeError so
47
+ # a rescue that counts a failed call keeps the run's finished work. How the run ends is the
48
+ # caller's: rescue this, or read rate_limit.spent?, to stop and resume later.
49
+ class RateLimitSpent < JudgeError
50
+ end
51
+
44
52
  # The child's environment is built FROM SCRATCH, never inherited. ANTHROPIC_API_KEY is
45
53
  # exported machine-wide on the operator's box, and a bare Open3.popen3(*args) hands it
46
54
  # to `claude -p`, where it takes auth precedence over the claude.ai login -- so runs
@@ -74,6 +82,7 @@ module OmniAI
74
82
  SSL_CERT_FILE NODE_EXTRA_CA_CERTS HTTP_PROXY HTTPS_PROXY NO_PROXY
75
83
  ].freeze
76
84
  BILLING_MODES = %i[subscription api].freeze
85
+ RATE_LIMIT_SPENT = "rate limit spent, no new call started"
77
86
 
78
87
  # A page legitimately takes minutes (the model re-transcribes it), so the default
79
88
  # deadline is generous; a caller's timeout: overrides it. Its job is only to cap a
@@ -88,7 +97,7 @@ module OmniAI
88
97
  ISOLATION_FLAGS = ["--tools", "", "--strict-mcp-config", "--no-session-persistence",
89
98
  "--setting-sources", "",].freeze
90
99
 
91
- attr_reader :billing
100
+ attr_reader :billing, :rate_limit
92
101
 
93
102
  # Billing is a RUN-level choice, so it is bound once at construction and the bound
94
103
  # adapter is handed to the existing injection points -- no `billing:` parameter has
@@ -101,11 +110,15 @@ module OmniAI
101
110
  # there is no default, so a caller cannot fall into metered billing by omission.
102
111
  def initialize(billing:)
103
112
  @billing = billing
113
+ @rate_limit = RateLimit.new
104
114
  child_env # validates the mode (and, for :api, the key) up front
105
115
  freeze
106
116
  end
107
117
 
108
- # Runs one `claude -p` judge under the billing mode bound at construction.
118
+ # Runs one `claude -p` judge under the billing mode bound at construction. A process that comes
119
+ # back rate limited is waited out and run again (see RateLimit). Only this method retries:
120
+ # run_with_timeout runs one process, so a subclass calling it directly keeps its own policy.
121
+ # A spent adapter stays spent, so a new run uses a new adapter.
109
122
  #
110
123
  # @param system [String] the judge's system prompt -- static and PHI-free.
111
124
  # @param packet [String] the PHI-bearing user content; sent on STDIN only.
@@ -120,6 +133,7 @@ module OmniAI
120
133
  # @param calls [#<<, nil] an optional collector. Each call that reaches the subprocess
121
134
  # pushes exactly one CallRecord however it ends, before any error is raised.
122
135
  # @return [Hash] the parsed verdict.
136
+ # @raise [RateLimitSpent] when the limit is spent, this call's or an earlier one's.
123
137
  # @raise [JudgeError] when the claude call errors or returns unparseable output.
124
138
  def call(system:, packet:, model: nil, schema: nil, timeout: JUDGE_TIMEOUT, calls: nil)
125
139
  # model stays effectively required -- there is no sensible default for a judge
@@ -133,11 +147,15 @@ module OmniAI
133
147
  args = ["claude", "-p", "--system-prompt", system, *ISOLATION_FLAGS, "--model", model,
134
148
  "--output-format", "json",]
135
149
  args += ["--json-schema", JSON.generate(schema)] if schema
136
- stdout = CallRecord.capture(calls, timeout_error: TimeoutError) do |on_stdout|
150
+ raise RateLimitSpent, RATE_LIMIT_SPENT if @rate_limit.spent?
151
+
152
+ run = CallRecord.capture(calls, timeout_error: TimeoutError, rate_limit: @rate_limit) do |on_stdout|
137
153
  run_with_timeout(args, packet, timeout:, env: child_env, on_stdout:)
138
154
  end
155
+ raise RateLimitSpent, RATE_LIMIT_SPENT if RateLimit.limited?(run[:stdout])
156
+ raise run[:error] if run[:error]
139
157
 
140
- schema ? parse_structured(stdout) : parse_output(stdout)
158
+ schema ? Verdict.parse_structured(run[:result]) : Verdict.parse_output(run[:result])
141
159
  end
142
160
 
143
161
  # The child environment for the bound billing mode -- built from the allowlist, so no
@@ -211,59 +229,6 @@ module OmniAI
211
229
  rescue Errno::ESRCH
212
230
  # already exited between the timeout check and the signal
213
231
  end
214
-
215
- # Parses the `claude -p --output-format json` envelope ({ is_error, result })
216
- # and returns the verdict Hash carried in `result`. The error message never
217
- # includes the raw output -- it holds PHI (verbatim source quotes).
218
- def parse_output(stdout)
219
- envelope = JSON.parse(stdout)
220
- raise JudgeError, "claude reported an error result" if envelope["is_error"]
221
-
222
- parse_verdict(envelope.fetch("result", "").to_s)
223
- rescue JSON::ParserError
224
- raise JudgeError, "unparseable judge output (#{stdout.bytesize} bytes)"
225
- end
226
-
227
- # The judge is told to emit bare JSON, but LLM output is nondeterministic -- the
228
- # same input can come back ```-fenced one run and wrapped in prose the next.
229
- # Strip fences first; if that still isn't valid JSON, fall back to the outermost
230
- # { ... } span. Raises (PHI-free) only when no JSON object is recoverable.
231
- def parse_verdict(result)
232
- cleaned = result.strip.gsub(/\A```(?:json)?\s*/m, "").gsub(/\s*```\z/m, "").strip
233
- JSON.parse(cleaned)
234
- rescue JSON::ParserError
235
- span = cleaned[/\{.*\}/m]
236
- raise JudgeError, "unparseable judge output (#{result.bytesize} bytes)" if span.nil?
237
-
238
- # A non-nil span can still be invalid JSON (e.g. prose braces). Rescue here too
239
- # so it surfaces as a PHI-free JudgeError, never a raw JSON::ParserError leaking
240
- # the (possibly PHI-bearing) content.
241
- begin
242
- JSON.parse(span)
243
- rescue JSON::ParserError
244
- raise JudgeError, "unparseable judge output (#{result.bytesize} bytes)"
245
- end
246
- end
247
-
248
- # Reads the schema-constrained object from a `claude -p --json-schema` envelope's
249
- # `structured_output` field (constrained decoding populates it). The free-form
250
- # `result` still holds the model's prose transcription -- its chain-of-thought --
251
- # but the deterministic artifact is `structured_output`. An absent field means the
252
- # model transcribed but never emitted the constrained object (refusal / max_tokens):
253
- # a COUNTED JudgeError, never a silent nil. Error messages stay PHI-free (byte counts
254
- # only) -- the envelope can echo verbatim source quotes.
255
- def parse_structured(stdout)
256
- envelope = JSON.parse(stdout)
257
- raise JudgeError, "claude reported an error result" if envelope["is_error"]
258
-
259
- verdict = envelope["structured_output"]
260
- raise JudgeError, "no structured_output in judge response (#{stdout.bytesize} bytes)" unless
261
- verdict.is_a?(Hash)
262
-
263
- verdict
264
- rescue JSON::ParserError
265
- raise JudgeError, "unparseable judge output (#{stdout.bytesize} bytes)"
266
- end
267
232
  end
268
233
  end
269
234
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "call_record"
4
+
5
+ module OmniAI
6
+ module Evals
7
+ module Adapters
8
+ # One run's `claude -p` rate limit, shared by every worker calling the adapter that holds it. A limited process is
9
+ # waited out and run again; one still limited after ATTEMPTS spends the limit, which starts no new call and wakes
10
+ # every call still waiting. State lives here rather than at class level, so a long-lived process isn't stopped for
11
+ # good by one run's limit.
12
+ class RateLimit
13
+ # The api_error_status of a limit that outlasted Claude Code's own retries: rate limited, overloaded.
14
+ STATUSES = [429, 529].freeze
15
+ # Processes per call.
16
+ ATTEMPTS = 5
17
+ # Seconds before the second process, doubling each try, plus up to this much jitter.
18
+ WAIT = 30
19
+
20
+ # @param stdout [String, nil] a process's stdout
21
+ # @return [Boolean]
22
+ def self.limited?(stdout)
23
+ envelope = CallRecord.envelope(stdout)
24
+ !envelope.nil? && STATUSES.include?(envelope["api_error_status"])
25
+ end
26
+
27
+ # @param attempts [Integer] processes run so far
28
+ # @return [Float] seconds to wait before the next
29
+ def self.delay(attempts)
30
+ (WAIT * (2**(attempts - 1))) + (rand * WAIT)
31
+ end
32
+
33
+ def initialize
34
+ @lock = Mutex.new
35
+ @changed = ConditionVariable.new
36
+ @spent = false
37
+ end
38
+
39
+ # @return [Boolean]
40
+ def spent?
41
+ @lock.synchronize { @spent }
42
+ end
43
+
44
+ # Spends the limit from outside too: a caller whose other calls share the account can stop this adapter's.
45
+ def spend!
46
+ @lock.synchronize do
47
+ @spent = true
48
+ @changed.broadcast
49
+ end
50
+ end
51
+
52
+ # @param stdout [String, nil] the last process's stdout
53
+ # @param attempts [Integer] processes run so far
54
+ # @return [Boolean] whether to run another, after waiting when it does
55
+ def run_again?(stdout, attempts)
56
+ return false unless self.class.limited?(stdout)
57
+
58
+ spend! if attempts >= ATTEMPTS
59
+ wait(self.class.delay(attempts))
60
+ end
61
+
62
+ # @param seconds [Numeric]
63
+ # @return [Boolean] true once the time is up; false, at once, when the limit is spent
64
+ def wait(seconds)
65
+ deadline = clock + seconds
66
+ @lock.synchronize do
67
+ until @spent
68
+ left = deadline - clock
69
+ return true unless left.positive?
70
+
71
+ @changed.wait(@lock, left)
72
+ end
73
+ false
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def clock
80
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OmniAI
4
4
  module Evals
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omniai-evals
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dean Singh
@@ -38,7 +38,9 @@ files:
38
38
  - lib/omniai/evals.rb
39
39
  - lib/omniai/evals/adapters/call_record.rb
40
40
  - lib/omniai/evals/adapters/claude_cli.rb
41
+ - lib/omniai/evals/adapters/claude_cli/verdict.rb
41
42
  - lib/omniai/evals/adapters/omniai.rb
43
+ - lib/omniai/evals/adapters/rate_limit.rb
42
44
  - lib/omniai/evals/assertions.rb
43
45
  - lib/omniai/evals/assertions/absence.rb
44
46
  - lib/omniai/evals/assertions/abstention.rb