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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +10 -8
- data/docs/anthropic.md +28 -83
- data/docs/configuration.md +31 -5
- data/docs/elevenlabs.md +1 -1
- data/docs/help-center.md +4 -4
- data/docs/openai.md +63 -4
- data/docs/vertex.md +1 -0
- data/lib/coolhand/base_interceptor.rb +137 -37
- data/lib/coolhand/batch_body_cap.rb +36 -0
- data/lib/coolhand/body_redactor.rb +111 -0
- data/lib/coolhand/body_stream_tap.rb +121 -0
- data/lib/coolhand/default_intercept_addresses.yml +8 -0
- data/lib/coolhand/{public_content_service.rb → help_center_service.rb} +1 -1
- data/lib/coolhand/logger_service.rb +54 -5
- data/lib/coolhand/net_http_interceptor.rb +190 -28
- data/lib/coolhand/open_ai/batch_result_processor.rb +139 -53
- data/lib/coolhand/open_ai/webhook_id_store.rb +7 -0
- data/lib/coolhand/open_ai/webhook_validator.rb +43 -28
- data/lib/coolhand/path_normalizer.rb +36 -0
- data/lib/coolhand/string_body_redactor.rb +83 -0
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand/vertex/batch_result_processor.rb +2 -2
- data/lib/coolhand/webhook_interceptor.rb +23 -0
- data/lib/coolhand.rb +7 -4
- metadata +8 -3
|
@@ -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
|
|
11
|
-
# otherwise every block-form read_body in
|
|
12
|
-
# ones on responses this gem never
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
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
|
-
|
|
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] =
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
54
|
-
|
|
58
|
+
window = batch_window
|
|
59
|
+
return unless window
|
|
55
60
|
|
|
56
|
-
request_items_by_id =
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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:
|
|
185
|
+
request_body: request_item["body"],
|
|
112
186
|
response_headers: {},
|
|
113
|
-
response_body:
|
|
114
|
-
status_code: status_code,
|
|
115
|
-
start_time:
|
|
116
|
-
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
|