batchwatch 0.2.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bfefddebcc183a5e63b03b570caa3d183103d4b661f90a617779241306201d6b
4
+ data.tar.gz: 02de063b1c8f22d8d2a5f93f2c3dda990b482af905ab811c5a630eb029289279
5
+ SHA512:
6
+ metadata.gz: e06032ef12b72d4d8213ae27271723afcbbb87a17087304ecda1ea999c8fe65777fc87a39b773cfc58faed151e4d685e94127e4186c4792d59bfd08af553e6fc
7
+ data.tar.gz: 8134786ba93b55272cbdf87db36e02581b600d229aa5ef08d8275810c93c29344ae583193b6488d8036f14787dac812a39eb3b356bc61132a7c7fc700c728908
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andreas Graae
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,176 @@
1
+ # batchwatch — Ruby client
2
+
3
+ Client for [batchwatch.dev](https://batchwatch.dev): crowdsourced measurement
4
+ of queue time on LLM batch APIs.
5
+
6
+ Batch endpoints cost 50% of the synchronous ones, but "completes within 24
7
+ hours" is impossible to plan around. batchwatch measures what the queue
8
+ actually does and answers one question: *should I use batch for this job?*
9
+
10
+ No dependencies. The standard library — `net/http`, `json`, `uri`, `socket`,
11
+ `tmpdir` — and nothing else.
12
+
13
+ ## Install
14
+
15
+ Point your `Gemfile` at the subdirectory:
16
+
17
+ ```ruby
18
+ gem "batchwatch", git: "https://github.com/batchwatch/client",
19
+ glob: "clients/ruby/*.gemspec"
20
+ ```
21
+
22
+ or vendor the three files under `lib/` — they have no third-party imports. A
23
+ RubyGems release is on the way.
24
+
25
+ ## Two lines
26
+
27
+ ```ruby
28
+ require "batchwatch"
29
+
30
+ bw = Batchwatch::Client.new(token: "bw_...") # token optional; falls back to $BATCHWATCH_TOKEN
31
+
32
+ # 1. before you submit — does this belong in the queue?
33
+ if bw.should_batch("gpt-5.6-sol", max_wait: "15m")
34
+ job = client.batches.create(...)
35
+ else
36
+ answer = client.chat.completions.create(...)
37
+ end
38
+
39
+ # 2. measure it, so the next person gets a better answer
40
+ bw.track("gpt-5.6-sol", input_tokens: 9720) do |t|
41
+ result = wait_for(job)
42
+ t.done(output_tokens: result.usage.completion_tokens)
43
+ end
44
+ ```
45
+
46
+ With a block, `track` closes the measurement for you, including on the error
47
+ path: an exception raised inside your block is recorded as `failed` and
48
+ re-raised untouched. Without a block it returns the tracking handle and you
49
+ call `t.done(...)` yourself.
50
+
51
+ Get a key with no email and no card:
52
+
53
+ curl -X POST https://batchwatch.dev/v1/keys -d '{"label":"my pipeline"}'
54
+
55
+ ## It fails open, always
56
+
57
+ If batchwatch is down, slow, or broken, your job must not notice. That is the
58
+ first requirement, ahead of collecting any data at all.
59
+
60
+ - Every submission runs on a background thread. `track()` and `t.done()` do no
61
+ network I/O on your thread.
62
+ - Two-second timeout by default (`BATCHWATCH_TIMEOUT`), applied to both the
63
+ connect and the read, so a server that accepts but never answers cannot hold
64
+ you.
65
+ - Every batchwatch error is swallowed and passed to the optional `logger` at
66
+ `debug` level. Nothing is printed unless you wire one up.
67
+ - `should_batch()` is the one synchronous call, because you are waiting for the
68
+ answer. If it cannot answer, you get your own `default` back — never a guess.
69
+ The default is `false`, "run it synchronously": being wrong that way costs
70
+ money, being wrong the other way blows a deadline.
71
+ - An exception raised inside your own `track` block is recorded as `failed` and
72
+ re-raised untouched. We swallow our errors, never yours.
73
+
74
+ `test/test_fail_open.rb` proves it against a port nothing listens on and
75
+ against a socket that accepts but never answers.
76
+
77
+ ## It never sends your content
78
+
79
+ No prompts, no completions, no system prompts, no tool calls, no file names.
80
+ The request body is built from a fixed allowlist — provider, model, mode,
81
+ endpoint, request count, token counts, timestamps, status — and everything
82
+ else is dropped by `Batchwatch.sanitize` on the way out. There is no field to put
83
+ text in.
84
+
85
+ `test/test_no_content.rb` asserts it on the bytes a real HTTP server received,
86
+ and includes a positive control so the test cannot pass by the client simply
87
+ sending nothing.
88
+
89
+ ## `output_tokens` defaults to `nil`, never `0`
90
+
91
+ You know your input tokens. You cannot know your output tokens before the model
92
+ has answered. So the default is absence (`nil`), not zero.
93
+
94
+ Zero is not a harmless placeholder here: output costs five to six times as much
95
+ as input, so a saving computed on zero output is systematically too low —
96
+ measured at 3.4x too low on a real model — and nothing in the response would
97
+ tell you. If you know a ceiling, pass `max_tokens` instead and the answer comes
98
+ back labelled as a ceiling.
99
+
100
+ Passing `output_tokens: 0` really does send `0`: zero is a measurement, absence
101
+ is not.
102
+
103
+ ## Spooling
104
+
105
+ When a measurement cannot be delivered, the completed record is appended to a
106
+ JSONL file and replayed later through `POST /v1/calls/complete`. Losing
107
+ measurements exactly when the network is bad means losing them exactly when
108
+ they are most interesting.
109
+
110
+ - Default path: `$BATCHWATCH_SPOOL`, or `batchwatch-spool.jsonl` in the system
111
+ temp directory (`Dir.tmpdir`). Set `BATCHWATCH_SPOOL=""` or pass
112
+ `spool: nil` to turn it off.
113
+ - The spool is replayed automatically, at most once a minute, right after a
114
+ successful call — that is the moment we know the network is up. Call
115
+ `bw.flush_spool` yourself from a shutdown hook if you want it drained on
116
+ exit.
117
+ - **Spooling requires a token.** `/v1/calls/complete` takes your own
118
+ timestamps, so it is closed to anonymous callers; without a key a spool file
119
+ could never be sent, and writing one would just leak disk. `bw.spool` is
120
+ `nil` when no token is set.
121
+ - The file is capped at 5 MB. Beyond that, measurements are dropped rather than
122
+ filling your disk.
123
+ - A replayed measurement can arrive twice if the original `PATCH` reached the
124
+ server but the response did not. That is deliberate: a duplicate is visible
125
+ in the dataset, a lost measurement is not.
126
+ - The file format is identical across the Python, TypeScript, Go and Ruby
127
+ clients, so a spool written by one can be flushed by another.
128
+ - Threads are handled by a mutex. Two *processes* sharing one spool file may
129
+ send a record twice — give each process its own `BATCHWATCH_SPOOL` if that
130
+ matters.
131
+
132
+ ## Configuration
133
+
134
+ | Argument | Environment | Default |
135
+ |---|---|---|
136
+ | `token` | `BATCHWATCH_TOKEN` | none (anonymous) |
137
+ | `base_url` | `BATCHWATCH_URL` | `https://batchwatch.dev` |
138
+ | `timeout` | `BATCHWATCH_TIMEOUT` | `2.0` seconds |
139
+ | `spool` | `BATCHWATCH_SPOOL` | `<tempdir>/batchwatch-spool.jsonl` |
140
+ | `enabled` | — | `true` |
141
+ | `logger` | — | `nil` (nothing logged) |
142
+
143
+ `enabled: false` turns every network call into a no-op, which is what you want
144
+ in CI.
145
+
146
+ ## API
147
+
148
+ - `should_batch(model, max_wait: nil, default: false, **kw) -> true/false`
149
+ - `advice(model, max_wait: nil, provider: "openai", input_tokens: nil, output_tokens: nil, max_tokens: nil, risk: "p90") -> Hash | nil`
150
+ - `wait_now(model, provider: "openai", mode: "batch") -> Hash | nil`
151
+ - `track(model, provider: "openai", mode: "batch", requests: 1, input_tokens: nil, endpoint: nil) { |t| ... }` — with or without a block
152
+ - `t.done(output_tokens: nil, status: "completed", ttfb_ms: nil)`
153
+ - `t.failed(status: "failed")`
154
+ - `t.started(input_tokens: ...)` when the count is only known after submission
155
+ - `flush(timeout: 5.0) -> true/false` — wait for outstanding submissions before exit
156
+ - `flush_spool(timeout: nil) -> Integer` — send what is on disk, returns accepted
157
+
158
+ ## Tests
159
+
160
+ rake test
161
+ # or a single suite:
162
+ ruby -Ilib -Itest test/test_fail_open.rb
163
+
164
+ 26 tests, no network beyond loopback. They start real HTTP servers on
165
+ ephemeral ports (raw `TCPServer`, port 0) rather than stubbing `Net::HTTP`:
166
+ the thing under test is network behaviour, so the network should be in the
167
+ test. The allowlist and fail-open tests carry positive controls, so a client
168
+ that sent nothing at all would fail them rather than pass.
169
+
170
+ ## Requirements
171
+
172
+ Requires Ruby 3.0 or newer. Tested on 3.1.
173
+
174
+ ## Licence
175
+
176
+ MIT
@@ -0,0 +1,670 @@
1
+ # frozen_string_literal: true
2
+
3
+ # batchwatch client.
4
+ #
5
+ # Two rules shape this file.
6
+ #
7
+ # **It never fails upward.** If batchwatch is down, slow, or broken, the
8
+ # caller's batch job must not notice. Every network call runs on a background
9
+ # thread with a short timeout, and every error is swallowed and logged at
10
+ # DEBUG. The only exception that ever leaves this module is the caller's own.
11
+ #
12
+ # **It never sends content.** Prompts, completions, system prompts, tool
13
+ # calls, file names - none of it. The body is built from a fixed allowlist of
14
+ # fields: provider, model, mode, endpoint, request count, token counts and
15
+ # timestamps. There is no field to put text in, by construction.
16
+ #
17
+ # require "batchwatch"
18
+ #
19
+ # bw = Batchwatch::Client.new(token: "bw_...") # token is optional
20
+ #
21
+ # # 1) before you submit: does this belong in the queue?
22
+ # if bw.should_batch("gpt-5.6-sol", max_wait: "15m")
23
+ # job = client.batches.create(...)
24
+ # else
25
+ # answer = client.chat.completions.create(...)
26
+ # end
27
+ #
28
+ # # 2) measure it
29
+ # bw.track("gpt-5.6-sol", input_tokens: 9720) do |t|
30
+ # # ...
31
+ # t.done(output_tokens: 4519)
32
+ # end
33
+ require "digest"
34
+ require "json"
35
+ require "net/http"
36
+ require "uri"
37
+ require "tmpdir"
38
+ require "time"
39
+
40
+ require_relative "spool"
41
+
42
+ module Batchwatch
43
+ VERSION = "0.2.1"
44
+
45
+ # How often, at most, we try to drain the spool on our own.
46
+ SPOOL_INTERVAL_S = 60.0
47
+
48
+ # How long completion waits for the start call's id to land. Only the
49
+ # background thread waits - the caller is long gone.
50
+ ID_WAIT_S = 3.0
51
+
52
+ # Field names allowed to leave the machine. Everything else is not in the
53
+ # body. The test test_no_content.rb pins this list. Same fields as the
54
+ # Python, Go, TypeScript and .NET clients.
55
+ #
56
+ # The last four (acted_verdict, deadline_s, quoted_p50_s, quoted_p90_s) are
57
+ # the outcome measurement (#101): the advice we gave ourselves, attached to
58
+ # the later completion so the SERVER can compare its own measured duration
59
+ # with what we promised. All of it is numbers and a decision we made
60
+ # ourselves - no new PII.
61
+ ALLOWED_FIELDS = %w[
62
+ provider model mode endpoint requests
63
+ input_tokens output_tokens started_at ended_at
64
+ status ttfb_ms source
65
+ acted_verdict deadline_s quoted_p50_s quoted_p90_s
66
+ ].freeze
67
+
68
+ def self.default_url
69
+ ENV["BATCHWATCH_URL"] || "https://batchwatch.dev"
70
+ end
71
+
72
+ def self.default_timeout
73
+ (ENV["BATCHWATCH_TIMEOUT"] || "2.0").to_f
74
+ end
75
+
76
+ def self.default_spool
77
+ v = ENV["BATCHWATCH_SPOOL"]
78
+ unless v.nil?
79
+ # An empty string turns the spool off.
80
+ return v.empty? ? nil : v
81
+ end
82
+ File.join(Dir.tmpdir, "batchwatch-spool.jsonl")
83
+ end
84
+
85
+ # Drop everything not on the allowlist.
86
+ #
87
+ # The last stop before the network. Even though no public method accepts free
88
+ # text, THIS function is the place you can point to when someone asks "how do
89
+ # you know a prompt cannot get out".
90
+ def self.sanitize(body)
91
+ body.each_with_object({}) do |(k, v), out|
92
+ out[k.to_s] = v if ALLOWED_FIELDS.include?(k.to_s)
93
+ end
94
+ end
95
+
96
+ def self.iso(time)
97
+ time.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
98
+ end
99
+
100
+ # ---------------------------------------------------------- idempotency (#30)
101
+ #
102
+ # The die-and-reflush problem: the process dies with a completed measurement
103
+ # on disk, restarts, and replays it. If the first attempt already reached the
104
+ # server, the replay must NOT count the same job twice - a duplicate row drags
105
+ # the percentiles (the whole product) toward the slow tail, because the slow
106
+ # calls are exactly the ones that time out and get resent.
107
+ #
108
+ # The fix is an Idempotency-Key that the server dedupes on. The rule that
109
+ # makes it work: the key IS DERIVED FROM THE MEASUREMENT, deterministically,
110
+ # so the replay reconstructs exactly the same key as the first attempt. A
111
+ # fresh key at send time (a UUID per call) would give the replay a DIFFERENT
112
+ # key, and nothing would be deduped. The key is a pure function of the
113
+ # measurement's content - the record IS its own stored key material.
114
+ #
115
+ # The scheme follows backfill.py's send(): a readable, named string built from
116
+ # the identifying fields; printable ASCII with no control characters.
117
+
118
+ def self.idem_field(body, key)
119
+ v = body[key] || body[key.to_sym]
120
+ v.nil? ? "" : v.to_s
121
+ end
122
+ private_class_method :idem_field
123
+
124
+ # A short, stable content hash of a record. Hex SHA-256 (first 16 bytes) of
125
+ # the record's canonical JSON, serialised in the same insertion order the
126
+ # client sends on the wire - so the same record hashes to the same value
127
+ # every time, on every run, and a replay reconstructs it exactly. Printable
128
+ # ASCII, no control characters, well within the server's 255-char key limit.
129
+ def self.content_hash(body)
130
+ Digest::SHA256.hexdigest(JSON.generate(body))[0, 32]
131
+ end
132
+ private_class_method :content_hash
133
+
134
+ # Per-record key for POST /v1/calls (the start call).
135
+ #
136
+ # Keeps the readable provider/model prefix for logs, then a content hash of
137
+ # the full (sanitized) start body. started_at is second-resolution, so two
138
+ # DISTINCT measurements of the same provider+model within one second would
139
+ # otherwise share a key - and the server refuses a reused key that carries a
140
+ # different body (409 body_differs), silently dropping the second measurement.
141
+ # Hashing the whole body keeps distinct measurements apart; an identical body
142
+ # (a retry or a die-and-reflush replay) still hashes the same and dedupes, so
143
+ # #30 holds. This key is only ever computed live at the start POST, never
144
+ # reconstructed from a spool line, so idem_complete's cross-client
145
+ # spool-replay key format is untouched.
146
+ def self.idem_start(body)
147
+ "bw-start-#{idem_field(body, 'provider')}-#{idem_field(body, 'model')}-" \
148
+ "#{content_hash(body)}"
149
+ end
150
+
151
+ # Per-request key for POST /v1/calls/complete. Derived from the group being
152
+ # sent, exactly like backfill.py: a single record is just a group of one.
153
+ # A replay of the SAME records chunks them identically (the spool preserves
154
+ # the order), so the same request carries the same key.
155
+ def self.idem_complete(group)
156
+ return nil if group.nil? || group.empty?
157
+
158
+ first = group.first
159
+ last = group.last
160
+ "bw-complete-#{idem_field(first, 'provider')}-#{idem_field(first, 'started_at')}-" \
161
+ "#{group.length}-#{idem_field(last, 'ended_at')}"
162
+ end
163
+
164
+ # Translate a max_wait into seconds, or nil if we cannot make sense of it.
165
+ #
166
+ # The caller typically writes "15m", "1h", "30s" or "2d" - the same language
167
+ # /v1/should-i-batch itself accepts. A bare number is read as seconds. If we
168
+ # cannot parse it, we send nil rather than a guess (rule #30).
169
+ def self.seconds(max_wait)
170
+ return nil if max_wait.nil?
171
+ return max_wait.to_f if max_wait.is_a?(Numeric)
172
+
173
+ s = max_wait.to_s.strip.downcase
174
+ return nil if s.empty?
175
+
176
+ factor = { "s" => 1.0, "m" => 60.0, "h" => 3600.0, "d" => 86_400.0 }
177
+ unit = s[-1]
178
+ if factor.key?(unit)
179
+ num = s[0..-2]
180
+ mult = factor[unit]
181
+ else
182
+ num = s
183
+ mult = 1.0
184
+ end
185
+ num = num.strip
186
+ return nil unless num.match?(/\A-?\d+(\.\d+)?\z/)
187
+
188
+ num.to_f * mult
189
+ end
190
+
191
+ # The fields from a stored advice that may be attached to a completion (#101).
192
+ #
193
+ # Only non-nil values come along: if a percentile or a deadline is missing,
194
+ # the field is OMITTED entirely - we do not invent a zero (rule #30). With no
195
+ # advice at all the result is an empty hash, so nothing is attached.
196
+ def self.outcome_fields(advice)
197
+ return {} if advice.nil?
198
+
199
+ out = { "acted_verdict" => advice["acted_verdict"] }
200
+ %w[deadline_s quoted_p50_s quoted_p90_s].each do |name|
201
+ out[name] = advice[name] unless advice[name].nil?
202
+ end
203
+ out
204
+ end
205
+
206
+ # A non-2xx response. Never reaches the caller of a public method - only the
207
+ # optional debug logger.
208
+ class HTTPError < StandardError
209
+ attr_reader :status, :body
210
+
211
+ def initialize(status, body)
212
+ @status = status
213
+ @body = body
214
+ super("batchwatch #{status}")
215
+ end
216
+ end
217
+
218
+ # The client. Every submission is non-blocking and fails open.
219
+ #
220
+ # Args:
221
+ # token: API key. Falls back to $BATCHWATCH_TOKEN. Optional for
222
+ # measurement, required to replay a spool.
223
+ # base_url: Default $BATCHWATCH_URL or https://batchwatch.dev
224
+ # timeout: Seconds per HTTP call. Default $BATCHWATCH_TIMEOUT or 2.0.
225
+ # enabled: false turns every network call into a no-op.
226
+ # spool: Path to the spool file, nil to turn it off. Default
227
+ # $BATCHWATCH_SPOOL or a file in the temp directory. Spooling is inactive
228
+ # without a token, because the replay route requires one.
229
+ # logger: Optional logger. All swallowed errors go to logger.debug.
230
+ class Client
231
+ attr_reader :token, :base, :timeout, :enabled, :spool
232
+
233
+ UNSET = Object.new
234
+ private_constant :UNSET
235
+
236
+ def initialize(token: nil, base_url: nil, timeout: nil, enabled: true,
237
+ spool: UNSET, logger: nil)
238
+ @token = token || ENV["BATCHWATCH_TOKEN"]
239
+ @base = (base_url || Batchwatch.default_url).sub(%r{/+\z}, "")
240
+ @timeout = timeout.nil? ? Batchwatch.default_timeout : timeout
241
+ @enabled = enabled
242
+ @logger = logger
243
+ @threads = []
244
+ @threads_lock = Mutex.new
245
+
246
+ path = spool.equal?(UNSET) ? Batchwatch.default_spool : spool
247
+ # Without a key /v1/calls/complete cannot accept it, so a spool file could
248
+ # never be sent. So we do not write it.
249
+ @spool = (path && @token) ? Spool.new(path, logger: logger) : nil
250
+ if path && !@token
251
+ debug("batchwatch: no token - spool disabled " \
252
+ "(/v1/calls/complete requires a key)")
253
+ end
254
+ @spool_last = 0.0
255
+ @spool_lock = Mutex.new
256
+ # Last advice per model (#101). When should_batch answers, we store here
257
+ # what we recommended + the quoted percentiles, so the NEXT completion for
258
+ # the same model can attach them. The correlation is a documented
259
+ # approximation: latest-advice-per-model, not per-job.
260
+ @advice = {}
261
+ @advice_lock = Mutex.new
262
+ end
263
+
264
+ # ----------------------------------------------------------------- core
265
+
266
+ # One HTTP call. Raises on error - only public methods swallow.
267
+ #
268
+ # +idem+ is an optional Idempotency-Key. On the write routes (/v1/calls and
269
+ # /v1/calls/complete) it lets the server dedupe a resend: the same body +
270
+ # the same key writes the measurement once, no matter how many times a
271
+ # die-and-reflush sends it (#30). The key is derived from the measurement,
272
+ # so the replay carries the same key as the first attempt. nil = no header.
273
+ def call(path, method: "GET", body: nil, timeout: nil, idem: nil)
274
+ uri = URI.parse(@base + path)
275
+ deadline = timeout || @timeout
276
+
277
+ req_class = case method
278
+ when "GET" then Net::HTTP::Get
279
+ when "POST" then Net::HTTP::Post
280
+ when "PATCH" then Net::HTTP::Patch
281
+ else raise ArgumentError, "unknown method #{method}"
282
+ end
283
+ req = req_class.new(uri)
284
+ req["user-agent"] = "batchwatch-ruby/#{VERSION}"
285
+ unless body.nil?
286
+ req["content-type"] = "application/json"
287
+ req.body = JSON.generate(body)
288
+ end
289
+ req["authorization"] = "Bearer #{@token}" if @token
290
+ req["idempotency-key"] = idem if idem && !idem.empty?
291
+
292
+ http = Net::HTTP.new(uri.host, uri.port)
293
+ http.use_ssl = (uri.scheme == "https")
294
+ # Both the connect and the read timeout, so a server that accepts but
295
+ # never answers cannot hold us longer than the deadline.
296
+ http.open_timeout = deadline
297
+ http.read_timeout = deadline
298
+
299
+ res = http.request(req)
300
+ code = res.code.to_i
301
+ raw = res.body || ""
302
+ raise HTTPError.new(code, raw) if code < 200 || code >= 300
303
+
304
+ raw.strip.empty? ? nil : JSON.parse(raw)
305
+ end
306
+
307
+ # Run on a background thread. Errors are swallowed - this must never take
308
+ # the caller's own call down with it.
309
+ def in_background(&block)
310
+ return nil unless @enabled
311
+
312
+ t = Thread.new do
313
+ begin
314
+ block.call
315
+ rescue HTTPError => e
316
+ debug("batchwatch #{e.status}: #{e.body}")
317
+ rescue StandardError => e
318
+ debug("batchwatch unavailable: #{e}")
319
+ end
320
+ end
321
+ # Clean up finished threads, so the list does not grow without bound in a
322
+ # long-running process.
323
+ @threads_lock.synchronize do
324
+ @threads.reject! { |x| !x.alive? }
325
+ @threads << t
326
+ end
327
+ t
328
+ end
329
+
330
+ # Wait for outstanding submissions. Call it before the process exits.
331
+ def flush(timeout: 5.0)
332
+ deadline = monotonic + timeout
333
+ snapshot = @threads_lock.synchronize { @threads.dup }
334
+ snapshot.each do |t|
335
+ rest = deadline - monotonic
336
+ t.join(rest > 0 ? rest : 0)
337
+ end
338
+ @threads_lock.synchronize { @threads.reject! { |t| !t.alive? } }
339
+ @threads_lock.synchronize { @threads.empty? }
340
+ end
341
+
342
+ # ---------------------------------------------------------- decision
343
+
344
+ # What the queue is doing right now, or nil if we cannot say.
345
+ def wait_now(model, provider: "openai", mode: "batch")
346
+ q = URI.encode_www_form(provider: provider, model: model, mode: mode)
347
+ r = call("/v1/wait?#{q}")
348
+ return nil if !r || r["verdict"] == "insufficient_data"
349
+
350
+ r
351
+ rescue StandardError => e
352
+ debug("batchwatch wait failed: #{e}")
353
+ nil
354
+ end
355
+
356
+ # The whole verdict. Returns nil if we cannot answer.
357
+ #
358
+ # output_tokens is usually UNKNOWN at this point - the model decides them.
359
+ # The default is therefore nil, not zero. Sending zero would make the
360
+ # server compute the saving on zero output, and output costs five to six
361
+ # times as much as input: the answer would be systematically too low,
362
+ # without anyone seeing it. If you know a ceiling, pass max_tokens.
363
+ def advice(model, max_wait: nil, provider: "openai",
364
+ input_tokens: nil, output_tokens: nil, max_tokens: nil,
365
+ risk: "p90")
366
+ q = { "provider" => provider, "model" => model, "risk" => risk }
367
+ { "input_tokens" => input_tokens, "output_tokens" => output_tokens,
368
+ "max_tokens" => max_tokens }.each do |name, value|
369
+ q[name] = value.to_i unless value.nil?
370
+ end
371
+ q["max_wait"] = max_wait unless max_wait.nil?
372
+ call("/v1/should-i-batch?#{URI.encode_www_form(q)}")
373
+ rescue StandardError => e
374
+ debug("batchwatch advice failed: #{e}")
375
+ nil
376
+ end
377
+
378
+ # true/false. On any doubt you get your default back.
379
+ #
380
+ # We never guess on the caller's behalf: if we cannot answer, the caller
381
+ # gets their own predetermined value. The default is false - "run it
382
+ # synchronously" - which is the safe way to be wrong, because a synchronous
383
+ # call just costs more, while an unexpected eight-hour queue can take a
384
+ # product down.
385
+ def should_batch(model, max_wait: nil, default: false, **kw)
386
+ r = advice(model, max_wait: max_wait, **kw)
387
+ return default unless r
388
+
389
+ answer = case r["verdict"]
390
+ when "run_batch" then true
391
+ when "run_sync", "batch_at" then false
392
+ else default
393
+ end
394
+ remember_advice(model, answer, max_wait, r)
395
+ answer
396
+ end
397
+
398
+ # Store the advice we just gave for THIS model (#101). Only numbers and our
399
+ # own decision end up here - no user data. The quoted percentiles are taken
400
+ # from the server's response; if one of them is missing, we store nil and
401
+ # simply do not attach it (rule #30: nothing invented as 0).
402
+ def remember_advice(model, acted_verdict, max_wait, answer)
403
+ num = ->(v) { v.is_a?(Numeric) ? v.to_f : nil }
404
+ advice = {
405
+ "acted_verdict" => acted_verdict,
406
+ "deadline_s" => Batchwatch.seconds(max_wait),
407
+ "quoted_p50_s" => num.call(answer["p50_s"]),
408
+ "quoted_p90_s" => num.call(answer["p90_s"])
409
+ }
410
+ @advice_lock.synchronize { @advice[model] = advice }
411
+ end
412
+
413
+ # The latest advice for a model, or nil. The approximation is
414
+ # latest-advice-per-model: the newest should_batch attaches to the next
415
+ # completion of the same model.
416
+ def get_advice(model)
417
+ @advice_lock.synchronize { @advice[model] }
418
+ end
419
+
420
+ # ------------------------------------------------------------- measurement
421
+
422
+ # Measure one call. The submission happens in the background.
423
+ #
424
+ # No exception from batchwatch ever reaches the caller. An exception from
425
+ # the caller's own block is recorded as "failed" and re-raised untouched.
426
+ #
427
+ # With a block it acts like the Python context manager: it closes the
428
+ # measurement for you, including on the error path. Without a block it
429
+ # returns the tracking handle and you call done() yourself.
430
+ def track(model, provider: "openai", mode: "batch", requests: 1,
431
+ input_tokens: nil, endpoint: nil)
432
+ t = Tracking.new(self, provider, model, mode, requests, input_tokens,
433
+ endpoint)
434
+ t.start
435
+ return t unless block_given?
436
+
437
+ begin
438
+ yield t
439
+ rescue Exception # rubocop:disable Lint/RescueException
440
+ # Everything - including Interrupt/SignalException - is recorded as
441
+ # failed and re-raised untouched. We swallow our own errors, never the
442
+ # caller's.
443
+ t.done(status: "failed")
444
+ raise
445
+ else
446
+ t.done unless t.finished?
447
+ end
448
+ end
449
+
450
+ # --------------------------------------------------------------- spool
451
+
452
+ # Send everything waiting on disk. Returns the number accepted.
453
+ #
454
+ # Synchronous and safe to call from a shutdown hook. Never raises. Records
455
+ # the server rejects as invalid are dropped - they will never become valid
456
+ # - and the count is logged.
457
+ def flush_spool(timeout: nil)
458
+ return 0 if !@spool || !@enabled
459
+
460
+ records = @spool.take
461
+ return 0 if records.empty?
462
+
463
+ sent = 0
464
+ rest = records.dup
465
+ until rest.empty?
466
+ group = rest.shift(MAX_BATCH)
467
+ begin
468
+ # The key is derived from the group itself: a replay after a crash
469
+ # chunks the records identically (the spool preserves the order), so
470
+ # the key is the same and the server dedupes the double write (#30).
471
+ r = call("/v1/calls/complete", method: "POST", body: group,
472
+ timeout: timeout || [@timeout, 10.0].max,
473
+ idem: Batchwatch.idem_complete(group))
474
+ rescue StandardError => e
475
+ debug("batchwatch: spool could not be sent: #{e}")
476
+ # The group that failed stays put along with the rest.
477
+ @spool.keep(group + rest)
478
+ return sent
479
+ end
480
+ sent += (r || {}).fetch("accepted", 0)
481
+ rejected = (r || {}).fetch("rejected", 0)
482
+ if rejected && rejected > 0
483
+ debug("batchwatch: #{rejected} spooled measurements were rejected and dropped")
484
+ end
485
+ end
486
+ @spool.keep([])
487
+ debug("batchwatch: #{sent} spooled measurements sent")
488
+ sent
489
+ end
490
+
491
+ # Called from the background thread AFTER a successful call - so we know the
492
+ # network is up right now, and we avoid hammering a server that is not
493
+ # answering anyway.
494
+ def maybe_flush_spool
495
+ return unless @spool
496
+
497
+ now = monotonic
498
+ @spool_lock.synchronize do
499
+ return if now - @spool_last < SPOOL_INTERVAL_S
500
+
501
+ @spool_last = now
502
+ end
503
+ flush_spool
504
+ rescue StandardError => e
505
+ debug("batchwatch: spool drain failed: #{e}")
506
+ end
507
+
508
+ # Store a COMPLETED measurement that could not be delivered.
509
+ def spool_measurement(body)
510
+ if @spool
511
+ @spool.append(body)
512
+ else
513
+ debug("batchwatch: the measurement was lost (no spool)")
514
+ end
515
+ end
516
+
517
+ private
518
+
519
+ def monotonic
520
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
521
+ end
522
+
523
+ def debug(msg)
524
+ @logger&.debug(msg)
525
+ end
526
+ end
527
+
528
+ # Handle for one measurement in flight. Returned by track().
529
+ class Tracking
530
+ attr_reader :id
531
+
532
+ def initialize(bw, provider, model, mode, requests, input_tokens, endpoint)
533
+ @bw = bw
534
+ @body = { "provider" => provider, "model" => model, "mode" => mode,
535
+ "requests" => requests, "endpoint" => endpoint }
536
+ @input_tokens = input_tokens
537
+ @id = nil
538
+ @t0 = Time.now
539
+ @finished = false
540
+ @lock = Mutex.new
541
+ @id_ready = false
542
+ @id_cond = ConditionVariable.new
543
+ end
544
+
545
+ def finished?
546
+ @lock.synchronize { @finished }
547
+ end
548
+
549
+ def start
550
+ body = @body.dup
551
+ body["input_tokens"] = @input_tokens
552
+ body["started_at"] = Batchwatch.iso(@t0)
553
+
554
+ start_body = Batchwatch.sanitize(body)
555
+ @bw.in_background do
556
+ begin
557
+ r = @bw.call("/v1/calls", method: "POST", body: start_body,
558
+ idem: Batchwatch.idem_start(start_body))
559
+ @lock.synchronize do
560
+ @id = r && r["id"]
561
+ @id_ready = true
562
+ @id_cond.broadcast
563
+ end
564
+ @bw.maybe_flush_spool if r
565
+ rescue StandardError
566
+ @lock.synchronize do
567
+ @id_ready = true
568
+ @id_cond.broadcast
569
+ end
570
+ raise
571
+ end
572
+ end
573
+ end
574
+
575
+ # Update the token count when it is only known after submission.
576
+ def started(input_tokens: nil)
577
+ @input_tokens = input_tokens unless input_tokens.nil?
578
+ end
579
+
580
+ # Close the measurement.
581
+ #
582
+ # output_tokens stays nil when you do not know it. It is never defaulted to
583
+ # zero: zero is a measurement, absence is not, and the server prices them
584
+ # differently on purpose.
585
+ def done(output_tokens: nil, status: "completed", ttfb_ms: nil)
586
+ @lock.synchronize do
587
+ return if @finished
588
+
589
+ @finished = true
590
+ end
591
+ ended = Time.now
592
+
593
+ # The outcome measurement (#101): the latest advice for THIS model,
594
+ # attached to the completion. No advice -> empty hash -> the fields are
595
+ # omitted entirely.
596
+ advice = Batchwatch.outcome_fields(@bw.get_advice(@body["model"]))
597
+
598
+ @bw.in_background do
599
+ # Wait briefly for the id to land from the start call.
600
+ wait_for_id
601
+ id = @lock.synchronize { @id }
602
+
603
+ full = @body.dup
604
+ full.merge!("input_tokens" => @input_tokens,
605
+ "output_tokens" => output_tokens, "status" => status,
606
+ "started_at" => Batchwatch.iso(@t0),
607
+ "ended_at" => Batchwatch.iso(ended))
608
+ full["ttfb_ms"] = ttfb_ms unless ttfb_ms.nil?
609
+ full.merge!(advice)
610
+
611
+ if id
612
+ begin
613
+ @bw.call("/v1/calls/#{id}", method: "PATCH", body: Batchwatch.sanitize(
614
+ { "status" => status, "output_tokens" => output_tokens,
615
+ "ttfb_ms" => ttfb_ms, "ended_at" => Batchwatch.iso(ended) }.merge(advice)
616
+ ))
617
+ next
618
+ rescue StandardError
619
+ # The server already has the start. A spooled resend can therefore
620
+ # produce a duplicate if the PATCH reached the server anyway -
621
+ # chosen on purpose: a duplicate can be seen in the dataset, a lost
622
+ # measurement cannot.
623
+ @bw.spool_measurement(Batchwatch.sanitize(full))
624
+ next
625
+ end
626
+ end
627
+
628
+ # The start call never reached the server. Send the whole measurement
629
+ # in one go instead of losing it. The key is derived from the
630
+ # measurement, so a later spool replay of exactly this record carries
631
+ # the SAME key and is deduped (#30).
632
+ cleaned = Batchwatch.sanitize(full)
633
+ begin
634
+ @bw.call("/v1/calls/complete", method: "POST", body: cleaned,
635
+ idem: Batchwatch.idem_complete([cleaned]))
636
+ rescue StandardError
637
+ @bw.spool_measurement(cleaned)
638
+ end
639
+ end
640
+ end
641
+
642
+ # Record the job as not-completed. An unfinished wait is not a wait.
643
+ def failed(status: "failed")
644
+ done(status: status)
645
+ end
646
+
647
+ private
648
+
649
+ def wait_for_id
650
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + ID_WAIT_S
651
+ @lock.synchronize do
652
+ until @id_ready
653
+ rest = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
654
+ break if rest <= 0
655
+
656
+ @id_cond.wait(@lock, rest)
657
+ end
658
+ end
659
+ end
660
+ end
661
+
662
+ @default = nil
663
+ @default_lock = Mutex.new
664
+
665
+ # Shortcut that uses a shared default client, to get started quickly.
666
+ def self.track(model, **kw, &blk)
667
+ @default_lock.synchronize { @default ||= Client.new }
668
+ @default.track(model, **kw, &blk)
669
+ end
670
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ # On-disk spool for measurements that could not be delivered.
4
+ #
5
+ # A measurement is worth the most exactly when the network is misbehaving, so
6
+ # that is the worst possible moment to lose one. Undeliverable *completed*
7
+ # measurements are therefore written to a JSONL file and replayed later through
8
+ # POST /v1/calls/complete.
9
+ #
10
+ # The file format is one JSON object per line, in exactly the form
11
+ # /v1/calls/complete accepts. It is the same format in the Python, Go,
12
+ # TypeScript and .NET clients, so a spool file written by one can be drained
13
+ # by another.
14
+ #
15
+ # Replay requires an API key: /v1/calls/complete takes the caller's own
16
+ # timestamps and is closed to anonymous callers for that reason. A client
17
+ # without a token therefore does not spool at all - a file no one can send is
18
+ # just a disk leak.
19
+ #
20
+ # Threads are handled with a mutex; separate processes sharing one spool file
21
+ # are not. Appending from multiple processes almost always goes fine, but two
22
+ # processes draining the same file at the same time may send the same
23
+ # measurement twice. Give each process its own BATCHWATCH_SPOOL if it matters.
24
+ require "json"
25
+
26
+ module Batchwatch
27
+ # The server's cap on one POST /v1/calls/complete.
28
+ MAX_BATCH = 500
29
+
30
+ # Cap on the spool file. If batchwatch is down for a week, a busy pipeline
31
+ # must not fill the user's disk. When the cap is reached, the measurement is
32
+ # dropped - and that is the right choice: his machine is not our storage.
33
+ MAX_BYTES = 5 * 1024 * 1024
34
+
35
+ # Append-only JSONL file of completed measurements waiting to be sent.
36
+ class Spool
37
+ attr_reader :path, :pending, :max_bytes
38
+
39
+ def initialize(path, max_bytes: MAX_BYTES, logger: nil)
40
+ @path = path
41
+ @pending = "#{path}.pending"
42
+ @max_bytes = max_bytes
43
+ @logger = logger
44
+ # The measurements come from background threads, one per completed call.
45
+ # Without the lock, two concurrent completions lose one of themselves:
46
+ # "find the end, write" is not atomic. Seen in a test with three at once:
47
+ # two lines made it.
48
+ @lock = Mutex.new
49
+ end
50
+
51
+ # ------------------------------------------------------------------ write
52
+
53
+ # Store one completed measurement. Returns true if it was stored.
54
+ #
55
+ # Never raises: failing a spool must not be worse than the network error
56
+ # that triggered the spool.
57
+ def append(record)
58
+ @lock.synchronize { write_one(record) }
59
+ rescue StandardError => e
60
+ debug("batchwatch: could not spool: #{e}")
61
+ false
62
+ end
63
+
64
+ # ------------------------------------------------------------------- read
65
+
66
+ # Move everything spooled into the pending file and return it.
67
+ #
68
+ # Returns a list of records. An empty list means there is nothing to send -
69
+ # also when the spool simply could not be read.
70
+ def take
71
+ @lock.synchronize { take_all }
72
+ rescue StandardError => e
73
+ debug("batchwatch: could not read spool: #{e}")
74
+ []
75
+ end
76
+
77
+ # Put records back after a partial or failed drain.
78
+ def keep(remaining)
79
+ @lock.synchronize do
80
+ if remaining && !remaining.empty?
81
+ write_all(@pending, remaining)
82
+ elsif File.exist?(@pending)
83
+ File.delete(@pending)
84
+ end
85
+ end
86
+ rescue StandardError => e
87
+ debug("batchwatch: could not write spool back: #{e}")
88
+ end
89
+
90
+ # Number of records waiting on disk. Best effort, never raises.
91
+ def size
92
+ @lock.synchronize { read_all(@pending).length + read_all(@path).length }
93
+ rescue StandardError => e
94
+ debug("batchwatch: could not count spool: #{e}")
95
+ 0
96
+ end
97
+
98
+ private
99
+
100
+ def write_one(record)
101
+ if File.exist?(@path) && File.size(@path) >= @max_bytes
102
+ debug("batchwatch: spool is full (#{@path}) - the measurement is dropped")
103
+ return false
104
+ end
105
+ dir = File.dirname(@path)
106
+ mkdir_p(dir) if dir && !dir.empty? && dir != "."
107
+
108
+ line = JSON.generate(record)
109
+ # "r+b"/"a+b"-like behaviour: we need to be able to READ the last byte,
110
+ # and a pure append mode is write-only. If the file ends mid-line after a
111
+ # crash, the next measurement must not be glued onto it - otherwise we
112
+ # lose not just the half line, but the whole one too.
113
+ File.open(@path, File::WRONLY | File::CREAT, 0o644) do |f|
114
+ f.seek(0, IO::SEEK_END)
115
+ size = f.pos
116
+ prefix = ""
117
+ if size > 0
118
+ # Read the last byte through a separate handle - WRONLY cannot read.
119
+ # If the trailing newline is missing, we push one in front.
120
+ last = File.open(@path, "rb") { |r| r.seek(-1, IO::SEEK_END); r.read(1) }
121
+ prefix = "\n" if last != "\n"
122
+ end
123
+ # ONE write. If it is split, another writer can slip its line in
124
+ # between the two halves.
125
+ f.write("#{prefix}#{line}\n")
126
+ end
127
+ debug("batchwatch: measurement spooled to #{@path}")
128
+ true
129
+ end
130
+
131
+ def take_all
132
+ records = read_all(@pending) + read_all(@path)
133
+ return [] if records.empty?
134
+
135
+ # The order is deliberate: write pending BEFORE the source is removed. A
136
+ # crash in between must produce duplicates, not loss - a duplicate can be
137
+ # seen and filtered out, a lost measurement does not exist.
138
+ write_all(@pending, records)
139
+ File.delete(@path) if File.exist?(@path)
140
+ records
141
+ end
142
+
143
+ def read_all(path)
144
+ return [] unless File.exist?(path)
145
+
146
+ out = []
147
+ File.foreach(path) do |line|
148
+ line = line.strip
149
+ next if line.empty?
150
+
151
+ begin
152
+ out << JSON.parse(line)
153
+ rescue JSON::ParserError
154
+ # A half-written line after a crash. Skip it instead of losing the
155
+ # rest of the file.
156
+ debug("batchwatch: unusable line in spool skipped")
157
+ end
158
+ end
159
+ out
160
+ end
161
+
162
+ def write_all(path, records)
163
+ File.open(path, "w") do |f|
164
+ records.each { |p| f.write("#{JSON.generate(p)}\n") }
165
+ end
166
+ end
167
+
168
+ # We avoid a dependency on 'fileutils' in the library by rolling a small
169
+ # recursive mkdir ourselves - the standard library can do all of it.
170
+ def mkdir_p(dir)
171
+ return if File.directory?(dir)
172
+
173
+ parent = File.dirname(dir)
174
+ mkdir_p(parent) if parent != dir && !File.directory?(parent)
175
+ begin
176
+ Dir.mkdir(dir)
177
+ rescue Errno::EEXIST
178
+ # Another thread got to make it. Fine.
179
+ end
180
+ end
181
+
182
+ def debug(msg)
183
+ @logger&.debug(msg)
184
+ end
185
+ end
186
+ end
data/lib/batchwatch.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # batchwatch - Ruby client for batchwatch.dev.
4
+ #
5
+ # Crowdsourced measurement of queue time on LLM batch APIs. Batch endpoints
6
+ # cost 50% of the synchronous ones, but "completes within 24 hours" is
7
+ # impossible to plan around. batchwatch measures what the queue actually does
8
+ # and answers one question: *should I use batch for this job?*
9
+ #
10
+ # Standard library only. No dependencies, and none planned.
11
+ #
12
+ # require "batchwatch"
13
+ #
14
+ # bw = Batchwatch::Client.new(token: "bw_...")
15
+ # if bw.should_batch("gpt-5.6-sol", max_wait: "15m")
16
+ # # ... send batch ...
17
+ # end
18
+ # bw.track("gpt-5.6-sol", input_tokens: 9720) do |t|
19
+ # t.done(output_tokens: 4519)
20
+ # end
21
+ require_relative "batchwatch/spool"
22
+ require_relative "batchwatch/client"
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: batchwatch
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Andreas Graae
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.15'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.15'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: Crowdsourced measurement of queue time on LLM batch APIs. Fails open
42
+ (a batchwatch outage never touches your job) and never sends content - only a fixed
43
+ allowlist of provider, model, mode, endpoint, request count, token counts and timestamps.
44
+ Standard library only, no dependencies.
45
+ email:
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - LICENSE
51
+ - README.md
52
+ - lib/batchwatch.rb
53
+ - lib/batchwatch/client.rb
54
+ - lib/batchwatch/spool.rb
55
+ homepage: https://batchwatch.dev
56
+ licenses:
57
+ - MIT
58
+ metadata:
59
+ homepage_uri: https://batchwatch.dev
60
+ source_code_uri: https://github.com/batchwatch/client
61
+ rubygems_mfa_required: 'true'
62
+ post_install_message:
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '3.0'
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 3.3.27
78
+ signing_key:
79
+ specification_version: 4
80
+ summary: Client for batchwatch.dev - measure queue time on LLM batch APIs without
81
+ ever blocking your job
82
+ test_files: []