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 +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +4 -0
- data/lib/omniai/evals/adapters/call_record.rb +45 -17
- data/lib/omniai/evals/adapters/claude_cli/verdict.rb +70 -0
- data/lib/omniai/evals/adapters/claude_cli.rb +22 -57
- data/lib/omniai/evals/adapters/rate_limit.rb +85 -0
- data/lib/omniai/evals/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 67cfea27bec341f1486a603f0cf4d3e77e31f0c5980ec819da55bef1e1b64a4e
|
|
4
|
+
data.tar.gz: 0b2c522085dd143abd823f2e0491629bc980c4882104379b3d9450ea353afa47
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`
|
|
12
|
-
#
|
|
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.
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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" =>
|
|
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
|
-
|
|
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(
|
|
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
|
data/lib/omniai/evals/version.rb
CHANGED
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.
|
|
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
|