coolhand 0.10.0 → 0.10.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.
@@ -1,22 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "net/http"
4
+ require "openssl"
3
5
  require "stringio"
6
+ require "timeout"
7
+ require "zlib"
8
+
9
+ require_relative "body_stream_tap"
10
+ require_relative "path_normalizer"
4
11
 
5
12
  module Coolhand
6
13
  module NetHttpInterceptor
7
14
  # Response streaming interceptor nested under NetHttpInterceptor
8
15
  module ResponseInterceptor
9
16
  def read_body(dest = nil, &block)
10
- # Only buffer while a #request call below is actively capturing —
11
- # otherwise every block-form read_body in the process (including
12
- # ones on responses this gem never intercepted) accumulates into a
13
- # thread-local that's never freed, which is an unbounded memory
14
- # leak on any thread that streams large non-LLM responses.
15
- return super unless block && Thread.current[:coolhand_capturing_stream]
17
+ # Only buffer this exact response while a #request call below is
18
+ # actively capturing it — otherwise every block-form read_body in
19
+ # the process (including ones on responses this gem never
20
+ # intercepted, such as a nested request made from inside the host's
21
+ # streaming block) accumulates into a thread-local that's never
22
+ # freed, and leaks one request's chunks into another's log.
23
+ return super unless block && Thread.current[:coolhand_capturing_stream].equal?(self)
16
24
 
17
25
  super do |chunk|
18
- Thread.current[:coolhand_stream_buffer] ||= +""
19
- Thread.current[:coolhand_stream_buffer] << chunk
26
+ Helpers.buffer_stream_chunk(chunk)
20
27
  yield(chunk)
21
28
  end
22
29
  end
@@ -119,19 +126,19 @@ module Coolhand
119
126
  previous_stream_buffer = Thread.current[:coolhand_stream_buffer]
120
127
  previous_capturing_stream = Thread.current[:coolhand_capturing_stream]
121
128
  Thread.current[:coolhand_stream_buffer] = nil
122
- Thread.current[:coolhand_capturing_stream] = true
129
+ Thread.current[:coolhand_capturing_stream] = nil
130
+ host_block_errors = []
131
+ tracked_block = Helpers.track_streaming_block(block, host_block_errors)
123
132
 
124
133
  begin
125
- response = super
126
- body_content = Thread.current[:coolhand_stream_buffer] || response&.body
127
- body_content = body_content.dup.force_encoding("UTF-8") if body_content.is_a?(String)
128
- status_code = response.respond_to?(:code) ? response.code.to_i : nil
129
- response_body = BaseInterceptor.parse_json(body_content)
134
+ response = super(req, body, &tracked_block)
135
+ status_code, response_body = Helpers.capture_response(response)
130
136
  rescue StandardError => e
131
- status_code = Helpers.extract_status_from_exception(e)
132
- response_body = { "error" => { "class" => e.class.name, "message" => e.message } }
137
+ status_code, response_body = Helpers.error_details(e, host_block_errors)
133
138
  raise
134
139
  ensure
140
+ # First, so the host's own stream is back on its request whatever fails below.
141
+ request_body = Helpers.finalize_request_body(captured_body, req)
135
142
  active.delete(self)
136
143
  Thread.current[:coolhand_stream_buffer] = previous_stream_buffer
137
144
  Thread.current[:coolhand_capturing_stream] = previous_capturing_stream
@@ -145,7 +152,7 @@ module Coolhand
145
152
  method: req.method,
146
153
  url: url,
147
154
  request_headers: BaseInterceptor.sanitize_headers(req),
148
- request_body: captured_body,
155
+ request_body: request_body,
149
156
  response_headers: BaseInterceptor.sanitize_headers(response),
150
157
  response_body: response_body,
151
158
  status_code: status_code,
@@ -166,6 +173,46 @@ module Coolhand
166
173
  module Helpers
167
174
  module_function
168
175
 
176
+ HOST_BLOCK_ERROR_MESSAGE = "[redacted: raised in host streaming block]"
177
+ # Socket-level failures can surface inside the host's block (read_body runs there); they
178
+ # carry no app data, so they keep their message even when they pass through the host block.
179
+ TRANSPORT_ERRORS = [
180
+ IOError, SystemCallError, Timeout::Error, Net::ProtocolError, OpenSSL::SSL::SSLError,
181
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Zlib::Error
182
+ ].freeze
183
+ private_constant :HOST_BLOCK_ERROR_MESSAGE, :TRANSPORT_ERRORS
184
+
185
+ # Wraps the host's streaming block so (a) read_body only buffers the response being
186
+ # captured here, and (b) exceptions raised by the host's own code can be told apart from
187
+ # transport errors (see TRANSPORT_ERRORS). Returns nil when there is no block.
188
+ def track_streaming_block(block, host_block_errors)
189
+ return unless block
190
+
191
+ proc do |res|
192
+ Thread.current[:coolhand_capturing_stream] = res
193
+ block.call(res)
194
+ rescue StandardError => e
195
+ host_block_errors << e unless transport_error?(e)
196
+ raise
197
+ end
198
+ end
199
+
200
+ # Matched by class, so a host block raising its own IOError/SystemCallError subclass keeps its
201
+ # message too; accepted trade-off, as telling those apart from socket failures isn't possible.
202
+ def transport_error?(error)
203
+ TRANSPORT_ERRORS.any? { |klass| error.is_a?(klass) }
204
+ end
205
+
206
+ # An error raised by the host's streaming block can carry app-level data in its message (and
207
+ # in a #status or message-embedded status code), so only its class is logged and no status is
208
+ # derived from it; transport errors keep their message and status. Returns [status, body].
209
+ def error_details(error, host_block_errors)
210
+ from_host = host_block_errors.any? { |host_error| host_error.equal?(error) }
211
+ status = from_host ? nil : extract_status_from_exception(error)
212
+ message = from_host ? HOST_BLOCK_ERROR_MESSAGE : error.message
213
+ [status, { "error" => { "class" => error.class.name, "message" => message } }]
214
+ end
215
+
169
216
  def should_capture?
170
217
  return true if Coolhand.configuration.debug_mode
171
218
 
@@ -184,24 +231,132 @@ module Coolhand
184
231
 
185
232
  content = body || req.body
186
233
  if content.nil? && req.respond_to?(:body_stream) && req.body_stream
187
- content = req.body_stream.read
188
- req.body_stream = StringIO.new(content)
234
+ result = capture_body_stream(req)
235
+ return result unless result.is_a?(String)
236
+
237
+ content = result
189
238
  end
190
239
  return nil if content.nil?
191
240
 
192
241
  cap_and_parse(content, req)
193
242
  end
194
243
 
244
+ # A body_stream request: the log body (a marker, or the BodyStreamTap resolved after the send),
245
+ # or the stream's content as a String when it was read here and still needs cap_and_parse.
246
+ def capture_body_stream(req)
247
+ # A declared Content-Length over the cap means we never need the bytes: skip
248
+ # before .read so a huge stream is not buffered just to be discarded.
249
+ too_large = oversized_declared_length(req)
250
+ return too_large if too_large
251
+
252
+ # No usable Content-Length (chunked): the size is unknown, so don't read ahead. Tap the
253
+ # stream instead and build the log body once Net::HTTP has sent it (finalize_request_body).
254
+ unless declared_content_length(req)
255
+ return skipped_capture_marker(req, "unsupported_body_stream") unless BodyStreamTap.tappable?(req.body_stream)
256
+
257
+ return BodyStreamTap.install(req)
258
+ end
259
+
260
+ content = req.body_stream.read
261
+ req.body_stream = StringIO.new(content)
262
+ content
263
+ end
264
+
265
+ # The effective cap: nil disables it; a non-Integer or negative value (e.g. an unparsed
266
+ # ENV string) falls back to the default rather than raising into the host's request or stream.
267
+ def capture_limit
268
+ limit = Coolhand.configuration.max_captured_body_bytes
269
+ return limit if limit.nil? || (limit.is_a?(Integer) && !limit.negative?)
270
+
271
+ Coolhand::Configuration::DEFAULT_MAX_CAPTURED_BODY_BYTES
272
+ end
273
+
274
+ def over_capture_limit?(size)
275
+ limit = capture_limit
276
+ !limit.nil? && size > limit
277
+ end
278
+
279
+ def body_too_large_marker(carrier, size)
280
+ skipped_capture_marker(carrier, "body_too_large", size_bytes: size, max_bytes: capture_limit)
281
+ end
282
+
283
+ # Returns a body_too_large marker when the request declares a Content-Length above the
284
+ # cap, else nil. Chunked/absent/malformed lengths return nil (the caller then taps the
285
+ # stream, which enforces the cap on the bytes actually sent).
286
+ def oversized_declared_length(req)
287
+ length = declared_content_length(req)
288
+ body_too_large_marker(req, length) if length && over_capture_limit?(length)
289
+ end
290
+
291
+ # Net::HTTP sends a chunked body without regard to any Content-Length header, so it is not a
292
+ # declaration of what goes on the wire then.
293
+ def declared_content_length(req)
294
+ req.content_length if req.respond_to?(:content_length) && !req.chunked?
295
+ rescue StandardError
296
+ nil
297
+ end
298
+
299
+ # The request body for the log. A tapped body_stream (see BodyStreamTap.install) only has its
300
+ # content once the request has been attempted, so it is resolved here and the host's own
301
+ # stream is put back on the request. Runs in #request's ensure: it must not raise.
302
+ def finalize_request_body(captured, req)
303
+ return captured unless captured.is_a?(BodyStreamTap)
304
+
305
+ captured.restore_stream(req)
306
+ captured.body_for_log(req)
307
+ rescue StandardError => e
308
+ Coolhand.log "❌ Error capturing request body: #{e.message}"
309
+ nil
310
+ end
311
+
312
+ # Appends a streamed response chunk to the thread-local capture buffer, up to the cap.
313
+ # Past the cap the buffer is dropped and replaced by a running byte count (an Integer),
314
+ # so memory stays bounded; the host always still receives every chunk because the caller
315
+ # yields it regardless. Only #request (via response_body_for_log) reads this slot.
316
+ def buffer_stream_chunk(chunk)
317
+ buffer = Thread.current[:coolhand_stream_buffer]
318
+ total = (buffer.is_a?(Integer) ? buffer : buffer.to_s.bytesize) + chunk.bytesize
319
+
320
+ Thread.current[:coolhand_stream_buffer] =
321
+ if buffer.is_a?(Integer) || over_capture_limit?(total)
322
+ total
323
+ else
324
+ (buffer || +"") << chunk
325
+ end
326
+ rescue StandardError => e
327
+ # Runs inside the host's read_body loop: a capture failure must never reach the host.
328
+ Coolhand.log "❌ Error buffering streamed response chunk: #{e.message}"
329
+ end
330
+
331
+ # [status_code, response_body] for the log. Called after the real request has succeeded, so a
332
+ # failure here is logged and swallowed rather than re-raised into the host app.
333
+ def capture_response(response)
334
+ captured = Thread.current[:coolhand_stream_buffer] || response&.body
335
+ # captured is a String, or an Integer byte count when a streamed body overflowed the cap.
336
+ [response.respond_to?(:code) ? response.code.to_i : nil, response_body_for_log(captured, response)]
337
+ rescue StandardError => e
338
+ Coolhand.log "❌ Error capturing response: #{e.message}"
339
+ [nil, nil]
340
+ end
341
+
342
+ # Builds the logged response body from either an Integer (streamed body that overflowed
343
+ # the cap, see buffer_stream_chunk) or the body String/nil. An oversized String is
344
+ # replaced by a marker before the dup/JSON.parse so we don't copy or parse it.
345
+ def response_body_for_log(body, response)
346
+ size = body.is_a?(Integer) ? body : body.to_s.bytesize
347
+ return body_too_large_marker(response, size) if body.is_a?(Integer) || over_capture_limit?(size)
348
+
349
+ body = body.dup.force_encoding("UTF-8") if body.is_a?(String)
350
+ BaseInterceptor.parse_json(body)
351
+ end
352
+
195
353
  def binary_upload?(req)
196
354
  content_type = req.respond_to?(:content_type) ? req.content_type : nil
197
355
  content_type && !content_type.match?(/json/i)
198
356
  end
199
357
 
200
358
  def cap_and_parse(content, req)
201
- max_bytes = Coolhand.configuration.max_captured_body_bytes
202
- if max_bytes && content.bytesize > max_bytes
203
- return skipped_capture_marker(req, "body_too_large", size_bytes: content.bytesize, max_bytes: max_bytes)
204
- end
359
+ return body_too_large_marker(req, content.bytesize) if over_capture_limit?(content.bytesize)
205
360
 
206
361
  BaseInterceptor.parse_json(content)
207
362
  end
@@ -227,10 +382,11 @@ module Coolhand
227
382
  uri = safe_parse(url)
228
383
  return false unless uri&.host
229
384
 
230
- return false if excluded_by_pattern?(uri)
385
+ raw_path = uri.path.to_s
386
+ path = normalize_path(raw_path)
387
+ return false if excluded_by_pattern?(uri, raw_path, path)
231
388
 
232
389
  host = uri.host.downcase.chomp(".")
233
- path = uri.path.to_s
234
390
  addresses = Coolhand.configuration.intercept_addresses
235
391
  authorities = [host, "#{host}:#{uri.port}"]
236
392
  return true if addresses.any? { |a| authorities.any? { |authority| address_matches?(authority, path, a) } }
@@ -251,12 +407,13 @@ module Coolhand
251
407
  nil
252
408
  end
253
409
 
254
- def excluded_by_pattern?(uri)
410
+ # The deny-list fails closed: a pattern that matches either the raw or the normalized path
411
+ # excludes the request, so normalization can only add exclusions, never lift one.
412
+ def excluded_by_pattern?(uri, raw_path, path)
255
413
  patterns = Coolhand.configuration.exclude_api_patterns
256
414
  return false if patterns.nil? || patterns.empty?
257
415
 
258
- path = uri.path.to_s
259
- matched = patterns.find { |pattern| path.include?(pattern) }
416
+ matched = patterns.find { |pattern| path.include?(pattern) || raw_path.include?(pattern) }
260
417
  if matched && Coolhand.configuration.debug_mode
261
418
  Coolhand.log "🚫 Skipping capture for #{BaseInterceptor.sanitize_url(uri.to_s)} " \
262
419
  "(matched exclude_api_pattern: \"#{matched}\")"
@@ -264,6 +421,11 @@ module Coolhand
264
421
  !!matched
265
422
  end
266
423
 
424
+ # See PathNormalizer: used for matching only, never for the URL that is sent or logged.
425
+ def normalize_path(path)
426
+ PathNormalizer.normalize(path)
427
+ end
428
+
267
429
  # intercept_path_patterns only ever applies to googleapis.com hosts, and only when the
268
430
  # user still wants Google API traffic intercepted at all — otherwise overriding
269
431
  # intercept_addresses to exclude Google hosts wouldn't actually stop Google API capture.
@@ -5,6 +5,16 @@ require_relative "../../coolhand"
5
5
  module Coolhand
6
6
  module OpenAi
7
7
  class BatchResultProcessor
8
+ # Batch input/output files carry relative URLs (e.g. "/v1/chat/completions"); logs need the
9
+ # absolute form so backend URL-shape classification matches the rest of the gem's OpenAI traffic.
10
+ OPENAI_API_BASE_URL = "https://api.openai.com"
11
+ SOURCE_API = "openai"
12
+ # Real IDs look like "batch_<hex>" and "file-<alnum>" (Azure uses UUID tails with hyphens).
13
+ # These values end up in OpenAI API calls made with the host app's key, so keep "/", "?", "#",
14
+ # "." and whitespace out of them.
15
+ BATCH_ID_PATTERN = /\Abatch_[A-Za-z0-9_-]{1,128}\z/
16
+ FILE_ID_PATTERN = /\Afile[-_][A-Za-z0-9_-]{1,128}\z/
17
+
8
18
  attr_reader :event_data
9
19
 
10
20
  def initialize(event_data:)
@@ -13,6 +23,7 @@ module Coolhand
13
23
 
14
24
  def call
15
25
  Rails.logger.info("[Interceptor] BatchResultProcessor: #{event_data}")
26
+ return unless valid_id?(event_data.is_a?(Hash) ? event_data["id"] : nil, BATCH_ID_PATTERN, "batch")
16
27
 
17
28
  case batch_info["status"]
18
29
  when "completed"
@@ -41,84 +52,159 @@ module Coolhand
41
52
  end
42
53
 
43
54
  def process_completed_batch
44
- input_file_id = batch_info["input_file_id"]
45
- return unless input_file_id
46
-
47
- output_file_id = batch_info["output_file_id"]
48
- return unless output_file_id
49
-
50
- # Download and process results
51
- batch_request_items = download_batch_results(input_file_id)
55
+ input_file_id, output_file_id = result_file_ids
56
+ return unless input_file_id && output_file_id
52
57
 
53
- # Download and process results
54
- batch_response_items = download_batch_results(output_file_id)
58
+ window = batch_window
59
+ return unless window
55
60
 
56
- request_items_by_id = batch_request_items.each_with_object({}) do |item, index|
57
- index[item["custom_id"]] ||= item
61
+ request_items_by_id = {}
62
+ each_batch_result(input_file_id) do |item, line_size|
63
+ request_items_by_id[item["custom_id"]] ||= request_entry(item, line_size)
58
64
  end
59
65
 
60
- batch_response_items.each do |response_item|
66
+ each_batch_result(output_file_id) do |response_item, line_size|
61
67
  request_item = request_items_by_id[response_item["custom_id"]]
62
-
63
- next unless request_item
64
-
65
- send_complete_request_log(request_id: response_item["response"]["request_id"],
66
- method: request_item["method"],
67
- url: request_item["url"],
68
- request_body: request_item["body"],
69
- response_body: response_item["response"]["body"],
70
- status_code: response_item["response"]["status_code"],
71
- start_time: batch_info["in_progress_at"].to_i,
72
- end_time: batch_info["completed_at"].to_i)
73
- rescue StandardError => e
74
- Rails.logger.error("[Interceptor] Failed to send request log: #{e.message}")
68
+ send_item_log(request_item, response_item, window, line_size) if request_item
75
69
  end
76
70
 
77
71
  Rails.logger.info("[Interceptor] Successfully processed OpenAI batch #{batch_info['id']}")
78
72
  end
79
73
 
80
- def download_batch_results(file_id)
81
- file_content = client.files.content(id: file_id)
74
+ # Returns [input_file_id, output_file_id], or nil when either is absent (quietly: a completed
75
+ # batch where every item failed legitimately has no output file) or malformed (logged).
76
+ def result_file_ids
77
+ input_file_id = batch_info["input_file_id"]
78
+ output_file_id = batch_info["output_file_id"]
79
+ return unless input_file_id && output_file_id
80
+ return unless valid_id?(input_file_id, FILE_ID_PATTERN, "input file") &&
81
+ valid_id?(output_file_id, FILE_ID_PATTERN, "output file")
82
82
 
83
- # Handle both string and array responses from the OpenAI client
84
- if file_content.is_a?(Array)
85
- # Client already parsed the JSONL into an array
86
- file_content
87
- else
88
- # Parse JSONL response manually
89
- file_content.split("\n").filter_map do |line|
90
- line.strip!
91
- next if line.empty?
83
+ [input_file_id, output_file_id]
84
+ end
85
+
86
+ def send_item_log(request_item, response_item, window, line_size)
87
+ send_complete_request_log(request_item: request_item, response_item: response_item, window: window,
88
+ line_size: line_size)
89
+ rescue StandardError => e
90
+ Rails.logger.error("[Interceptor] Failed to send request log: #{e.message}")
91
+ end
92
+
93
+ # What is kept of each input item while the output file is read: only the fields that get
94
+ # logged, with the body capped (see BatchBodyCap) so the index stays small. The model is read
95
+ # from the full body first, since a capped body is just a marker.
96
+ def request_entry(item, line_size)
97
+ { "method" => item["method"], "url" => item["url"], "model" => item_model(item["body"]),
98
+ "body" => BatchBodyCap.cap(item["body"], source_size: line_size) }
99
+ end
100
+
101
+ # Returns [start_time, end_time] or nil (after logging) when OpenAI didn't report usable
102
+ # in_progress_at/completed_at epoch seconds — better to skip than log epoch-0 timestamps.
103
+ def batch_window
104
+ started = batch_info["in_progress_at"]
105
+ completed = batch_info["completed_at"]
106
+ unless [started, completed].all? { |t| t.is_a?(Integer) && t.positive? }
107
+ Rails.logger.error("[Interceptor] OpenAI batch #{batch_info['id']} has a missing or invalid " \
108
+ "in_progress_at/completed_at; skipping request log")
109
+ return
110
+ end
111
+
112
+ warn_inverted_window if completed < started
113
+ [Time.at(started), Time.at([started, completed].max)]
114
+ end
92
115
 
93
- JSON.parse(line)
94
- end
116
+ def warn_inverted_window
117
+ Rails.logger.warn("[Interceptor] OpenAI batch #{batch_info['id']} has a completed_at before its " \
118
+ "in_progress_at; clamping duration_ms to 0")
119
+ end
120
+
121
+ def valid_id?(value, pattern, label)
122
+ return true if value.is_a?(String) && value.match?(pattern)
123
+
124
+ Rails.logger.error("[Interceptor] OpenAI batch has a missing or invalid #{label} ID " \
125
+ "(#{value.to_s[0, 100].inspect}); skipping")
126
+ false
127
+ end
128
+
129
+ # Yields each item of a batch input/output file with the byte size of the JSONL line it came
130
+ # from (nil when the client had already parsed it). A String/StringIO/IO result is read a line
131
+ # at a time, so only one parsed item exists at once instead of the whole file split into lines.
132
+ # A client that returns the whole file already parsed (an Array, or a Hash for a one-line file)
133
+ # has loaded it already; that is out of reach here.
134
+ def each_batch_result(file_id, &block)
135
+ file_content = download_batch_file(file_id)
136
+
137
+ case file_content
138
+ when nil then nil
139
+ when Array then file_content.each { |item| yield(item, nil) if item.is_a?(Hash) }
140
+ when Hash then yield(file_content, nil)
141
+ else each_jsonl_item(file_content, &block)
95
142
  end
96
- rescue JSON::ParserError => e
97
- Rails.logger.error("[Interceptor] Failed to parse OpenAI batch results: #{e.message}")
98
- []
143
+ end
144
+
145
+ def download_batch_file(file_id)
146
+ client.files.content(id: file_id)
99
147
  rescue StandardError => e
100
148
  Rails.logger.error("[Interceptor] Failed to download OpenAI batch results: #{e.message}")
101
- []
149
+ nil
102
150
  end
103
151
 
104
- def send_complete_request_log(request_id:, method:, url:, request_body:, response_body:, status_code:,
105
- start_time:, end_time:)
152
+ # A malformed line is skipped (JSONL lines are independent) and reported once at the end.
153
+ def each_jsonl_item(file_content)
154
+ skipped = 0
155
+ file_content.each_line do |line|
156
+ size = line.bytesize
157
+ next unless line.b.match?(/\S/) # byte-wise: String#strip raises on invalid UTF-8
158
+
159
+ item = parse_jsonl_line(line)
160
+ item ? yield(item, size) : skipped += 1
161
+ end
162
+ return if skipped.zero?
163
+
164
+ Rails.logger.error("[Interceptor] Failed to parse OpenAI batch results: skipped #{skipped} malformed line(s)")
165
+ end
166
+
167
+ # Only a JSON object is an item; any other valid JSON (a number, an array) counts as malformed.
168
+ def parse_jsonl_line(line)
169
+ item = JSON.parse(line)
170
+ item if item.is_a?(Hash)
171
+ rescue JSON::ParserError, EncodingError
172
+ nil
173
+ end
174
+
175
+ def send_complete_request_log(request_item:, response_item:, window:, line_size:)
176
+ start_time, end_time = window
177
+ response = response_item["response"]
106
178
  BaseInterceptor.send_complete_request_log(
107
- request_id: request_id,
108
- method: method,
109
- url: url,
179
+ request_id: response["request_id"],
180
+ method: request_item["method"],
181
+ url: absolute_url(request_item["url"]),
182
+ source_api: SOURCE_API,
183
+ model: request_item["model"] || item_model(response["body"]),
110
184
  request_headers: {},
111
- request_body: request_body,
185
+ request_body: request_item["body"],
112
186
  response_headers: {},
113
- response_body: response_body,
114
- status_code: status_code,
115
- start_time: Time.at(start_time),
116
- end_time: Time.at(end_time),
187
+ response_body: BatchBodyCap.cap(response["body"], source_size: line_size),
188
+ status_code: response["status_code"],
189
+ start_time: start_time,
190
+ end_time: end_time,
117
191
  duration_ms: ((end_time - start_time) * 1000).to_i,
118
192
  is_streaming: false
119
193
  )
120
194
  end
121
195
 
196
+ def absolute_url(url)
197
+ url.is_a?(String) && url.start_with?("/") ? "#{OPENAI_API_BASE_URL}#{url}" : url
198
+ end
199
+
200
+ def item_model(*bodies)
201
+ bodies.each do |body|
202
+ model = body["model"] if body.is_a?(Hash)
203
+ return model if model.is_a?(String) && Coolhand.required_field?(model)
204
+ end
205
+ nil
206
+ end
207
+
122
208
  def batch_info
123
209
  @batch_info ||= client.batches.retrieve(id: event_data["id"])
124
210
  end
@@ -27,6 +27,13 @@ module Coolhand
27
27
  end
28
28
  end
29
29
 
30
+ # Forgets a claim so the same id can be claimed again (used when processing failed and the
31
+ # sender retries). A no-op for an unknown id.
32
+ def release!(id)
33
+ @mutex.synchronize { @entries.delete(id) }
34
+ nil
35
+ end
36
+
30
37
  def seen?(id)
31
38
  @mutex.synchronize do
32
39
  prune