coolhand 0.9.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.
@@ -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
@@ -20,6 +20,7 @@ module Coolhand
20
20
 
21
21
  def valid?
22
22
  @errors.clear
23
+ @claimed_id = nil
23
24
  @payload = request.raw_post || request.body.read
24
25
 
25
26
  return false unless payload_valid?
@@ -28,7 +29,9 @@ module Coolhand
28
29
  secret_bytes = extract_secret_bytes
29
30
  webhook_signature, webhook_timestamp, webhook_id = extract_webhook_headers
30
31
 
31
- return validate_headers_in_non_production_env unless webhook_signature && webhook_timestamp && webhook_id
32
+ unless webhook_signature && webhook_timestamp && webhook_id
33
+ return reject_unsigned("Missing OpenAI webhook signature, timestamp, or id headers")
34
+ end
32
35
 
33
36
  verify_signature(webhook_signature, webhook_timestamp, webhook_id, secret_bytes)
34
37
  end
@@ -37,12 +40,29 @@ module Coolhand
37
40
  @errors.join(", ")
38
41
  end
39
42
 
43
+ # Gives back the webhook-id claim made by the last successful `valid?` so a same-id retry
44
+ # (e.g. after processing failed) isn't rejected as a replay. A no-op when nothing was claimed
45
+ # or when the store has no `release!` (custom stores may implement only `claim!`).
46
+ def release_claim!
47
+ claimed_id = @claimed_id
48
+ return unless claimed_id
49
+
50
+ @claimed_id = nil
51
+ id_store.release!(claimed_id) if id_store.respond_to?(:release!)
52
+ rescue StandardError => e
53
+ # Best effort: a failing store must not stop the caller from rejecting the request.
54
+ Rails.logger.error("OpenAI webhook id release failed: #{e.message}")
55
+ end
56
+
40
57
  private
41
58
 
42
59
  attr_reader :webhook_secret
43
60
 
61
+ # Only a nil payload counts as missing. An empty string is a real (empty) body, so it continues
62
+ # to the signature check, where it is accepted only if it was actually signed.
44
63
  def payload_valid?
45
64
  return true if @payload
65
+ return reject_unsigned("Empty webhook payload") if Coolhand.required_field?(webhook_secret)
46
66
 
47
67
  enforce_or_allow("Empty webhook payload",
48
68
  "Empty webhook payload - allowing in #{Rails.env} environment")
@@ -57,10 +77,8 @@ module Coolhand
57
77
  # check is skipped with a warning.
58
78
  def enforce_or_allow(problem, allow_warning)
59
79
  if should_enforce_strict_validation?
60
- @errors << "#{problem} - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
61
- "not in development/test allowlist)"
62
- Rails.logger.error(@errors.last)
63
- false
80
+ reject("#{problem} - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
81
+ "not in development/test allowlist)")
64
82
  else
65
83
  Rails.logger.warn(allow_warning)
66
84
  true
@@ -83,16 +101,15 @@ module Coolhand
83
101
  [webhook_signature, webhook_timestamp, webhook_id]
84
102
  end
85
103
 
86
- def validate_headers_in_non_production_env
87
- if should_enforce_strict_validation?
88
- @errors << "Missing OpenAI webhook signature, timestamp, or id headers - " \
89
- "rejecting webhook (Rails.env=#{Rails.env.inspect} not in development/test allowlist)"
90
- Rails.logger.error(@errors.last)
91
- false
92
- else
93
- Rails.logger.warn("Missing OpenAI webhook headers - skipping verification in #{Rails.env}")
94
- true
95
- end
104
+ # A secret is configured, so anything that can't be verified is rejected in every environment.
105
+ def reject_unsigned(problem)
106
+ reject("#{problem} - rejecting webhook")
107
+ end
108
+
109
+ def reject(message)
110
+ @errors << message
111
+ Rails.logger.error(message)
112
+ false
96
113
  end
97
114
 
98
115
  def verify_signature(webhook_signature, webhook_timestamp, webhook_id, secret_bytes)
@@ -102,11 +119,7 @@ module Coolhand
102
119
  # An empty key (a bare "whsec_" secret) makes the HMAC forgeable by anyone, so never accept it.
103
120
  signature_valid = !secret_bytes.empty? && signature_matches?(webhook_signature, expected_signature)
104
121
 
105
- unless signature_valid
106
- @errors << "OpenAI webhook signature verification failed"
107
- Rails.logger.error(@errors.last)
108
- return false
109
- end
122
+ return reject("OpenAI webhook signature verification failed") unless signature_valid
110
123
 
111
124
  return false unless timestamp_fresh?(webhook_timestamp)
112
125
  return false unless webhook_id_unused?(webhook_id)
@@ -134,18 +147,20 @@ module Coolhand
134
147
  age = (Time.now.to_i - webhook_timestamp.to_i).abs
135
148
  return true if age <= tolerance
136
149
 
137
- @errors << "OpenAI webhook timestamp outside replay-protection tolerance window"
138
- Rails.logger.error(@errors.last)
139
- false
150
+ reject("OpenAI webhook timestamp outside replay-protection tolerance window")
140
151
  end
141
152
 
153
+ # The timestamp check accepts anything within +/- tolerance of now, so a request stamped
154
+ # `now + tolerance` stays fresh until `now + 2 * tolerance`. Remember the id for twice the
155
+ # tolerance so it can't be replayed after its claim expires but while it is still fresh.
142
156
  def webhook_id_unused?(webhook_id)
143
- tolerance = Coolhand.configuration.webhook_replay_tolerance_seconds
144
- return true if id_store.claim!(webhook_id, tolerance)
157
+ ttl = Coolhand.configuration.webhook_replay_tolerance_seconds * 2
158
+ if id_store.claim!(webhook_id, ttl)
159
+ @claimed_id = webhook_id
160
+ return true
161
+ end
145
162
 
146
- @errors << "OpenAI webhook id already processed (replay protection)"
147
- Rails.logger.error(@errors.last)
148
- false
163
+ reject("OpenAI webhook id already processed (replay protection)")
149
164
  end
150
165
 
151
166
  def id_store
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ module PathNormalizer
5
+ UNRESERVED_CHAR = /\A[A-Za-z0-9\-._~]\z/
6
+ private_constant :UNRESERVED_CHAR
7
+
8
+ module_function
9
+
10
+ # Path used for matching only (the URL that is sent and logged is never touched), so
11
+ # sloppy joins like "/openai//files", "/openai/./files", "/openai/%66iles" or
12
+ # "/v2/chat/../../v1/rerank" can't slip past an exclude pattern or a path anchor.
13
+ # Decodes unreserved-character escapes (%2F and other reserved escapes stay encoded, and
14
+ # decoding is one pass, so "%252e" stays literal), collapses repeated "/", and resolves
15
+ # "." and ".." segments without ever climbing above the root. Case is left as is. A path
16
+ # that can't be normalized falls back to the raw one rather than raising, and an empty
17
+ # path comes back as "/".
18
+ def normalize(path)
19
+ decoded = path.gsub(/%\h\h/) do |escape|
20
+ char = escape[1, 2].hex.chr
21
+ UNRESERVED_CHAR.match?(char) ? char : escape
22
+ end
23
+ segments = decoded.split("/").each_with_object([]) do |segment, kept|
24
+ case segment
25
+ when "", "." then next
26
+ when ".." then kept.pop
27
+ else kept << segment
28
+ end
29
+ end
30
+ directory = !segments.empty? && decoded.end_with?("/", "/.", "/..")
31
+ "/#{segments.join('/')}#{'/' if directory}"
32
+ rescue StandardError
33
+ path
34
+ end
35
+ end
36
+ end
@@ -29,7 +29,9 @@ module Coolhand
29
29
 
30
30
  # Also returns the response, for endpoints that carry pagination in headers rather than the body.
31
31
  def get_json_with_headers(url, noun)
32
- raise Error, "#{noun} request failed: an API key is required" unless Coolhand.required_field?(api_key)
32
+ if api_key_required? && !Coolhand.required_field?(api_key)
33
+ raise Error, "#{noun} request failed: an API key is required"
34
+ end
33
35
 
34
36
  response = perform_get(url, noun)
35
37
 
@@ -38,6 +40,11 @@ module Coolhand
38
40
  [parse_json_body(response.body, noun), response]
39
41
  end
40
42
 
43
+ # Overridden by services over public endpoints, which work anonymously and send the key only if set.
44
+ def api_key_required?
45
+ true
46
+ end
47
+
41
48
  private
42
49
 
43
50
  def perform_get(url, noun)
@@ -45,7 +52,9 @@ module Coolhand
45
52
  http = build_http(uri)
46
53
 
47
54
  request = Net::HTTP::Get.new(uri.request_uri)
48
- apply_headers(request, "Accept" => "application/json", "X-API-Key" => api_key)
55
+ headers = { "Accept" => "application/json" }
56
+ headers["X-API-Key"] = api_key if Coolhand.required_field?(api_key)
57
+ apply_headers(request, headers)
49
58
 
50
59
  # Net::HTTP does not follow redirects, so a 3xx raises rather than replaying the API key at
51
60
  # an unapproved host. without_capture is the same recursion guard send_request uses.
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Redacts credentials in raw String bodies, which BodyRedactor's tree walk never sees: a streamed
5
+ # (server-sent event) response arrives as one String of `data: {...}` lines, and a webhook body
6
+ # may be raw JSON text. Each JSON object/array payload is parsed, run through BodyRedactor and
7
+ # re-serialized; text that is not JSON (plain text, `[DONE]`, `event:` and comment lines, a
8
+ # payload that fails to parse) is left byte for byte. A JSON payload is always re-serialized, even
9
+ # when nothing in it needed redacting: JSON.parse keeps only the last of a repeated key, so
10
+ # "nothing changed" cannot be trusted to mean "no credential in the original text".
11
+ module StringBodyRedactor
12
+ module_function
13
+
14
+ NESTING_MARKER = { "_coolhand_capture_skipped" => "nesting_too_deep" }.freeze
15
+ FAILED_MARKER = { "_coolhand_capture_skipped" => "redaction_failed" }.freeze
16
+
17
+ # A `data:` field at the start of a line (LF, CRLF or bare CR), or after a byte order mark,
18
+ # which the SSE spec ignores. Whitespace after the colon is captured apart from the payload so
19
+ # a rewritten line keeps its own.
20
+ DATA_LINE = /(?<![^\r\n\uFEFF])data:([ \t]*)([^\r\n]*)/
21
+ JSON_CONTAINER = /\A\uFEFF?\s*[\[{]/
22
+
23
+ def redact(body)
24
+ text = utf8_text(body)
25
+ json = redacted_json(text) if text.match?(JSON_CONTAINER)
26
+ json || redact_stream(body, text)
27
+ rescue StandardError => e
28
+ # Fail closed: never log a body that could not be checked. The message is left out because
29
+ # a parser error can quote the payload.
30
+ Coolhand.log "❌ Error redacting string body: #{e.class}"
31
+ FAILED_MARKER
32
+ end
33
+
34
+ # Captured bodies are UTF-8, but a binary-tagged, other-encoded or invalid-UTF-8 String (a
35
+ # webhook body can be any of them) is not worth a failure: inspect a UTF-8 working copy, with
36
+ # invalid bytes replaced, so every line can still be checked.
37
+ def utf8_text(body)
38
+ text = case body.encoding
39
+ when Encoding::UTF_8 then body
40
+ when Encoding::BINARY then body.dup.force_encoding(Encoding::UTF_8)
41
+ else body.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
42
+ end
43
+ text.valid_encoding? ? text : text.scrub
44
+ end
45
+
46
+ # One pass with gsub (linear in the body); the original String is returned when no line differs
47
+ # once re-serialized (providers emit compact JSON, so a clean stream usually round-trips as is).
48
+ def redact_stream(body, text)
49
+ changed = false
50
+ redacted = text.gsub(DATA_LINE) do |line|
51
+ match = Regexp.last_match
52
+ payload = match[2].match?(JSON_CONTAINER) ? redacted_json(match[2]) : nil
53
+ next line if payload.nil? || payload == match[2]
54
+
55
+ changed = true
56
+ "data:#{match[1]}#{payload}"
57
+ end
58
+ changed ? redacted : body
59
+ end
60
+
61
+ # The redacted, re-serialized JSON; nil when the text should stay as is (not a JSON
62
+ # object/array, or unparsable). Anything else that goes wrong yields a `redaction_failed`
63
+ # marker rather than the unchecked text.
64
+ def redacted_json(text)
65
+ # NaN/Infinity are not JSON, but some servers emit them; a payload that failed to parse
66
+ # would be logged unredacted next to its credential-named fields.
67
+ parsed = JSON.parse(text.delete_prefix("\uFEFF"), allow_nan: true)
68
+ return unless parsed.is_a?(Hash) || parsed.is_a?(Array)
69
+
70
+ redacted = BodyRedactor.redact(parsed)
71
+ JSON.generate(redacted, allow_nan: true)
72
+ rescue JSON::NestingError
73
+ JSON.generate(NESTING_MARKER)
74
+ rescue JSON::ParserError
75
+ nil
76
+ rescue StandardError => e
77
+ # e.g. a string with a lone surrogate that parses but cannot be serialized: fail closed for
78
+ # this payload alone so the rest of the body is still redacted.
79
+ Coolhand.log "❌ Error redacting JSON payload: #{e.class}"
80
+ JSON.generate(FAILED_MARKER)
81
+ end
82
+ end
83
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coolhand
4
- VERSION = "0.9.0"
4
+ VERSION = "0.10.1"
5
5
  end
@@ -111,9 +111,9 @@ module Coolhand
111
111
  source_api: SOURCE_API,
112
112
  model: model,
113
113
  request_headers: {},
114
- request_body: batch_item["request"],
114
+ request_body: BatchBodyCap.cap(batch_item["request"]),
115
115
  response_headers: {},
116
- response_body: batch_item["response"],
116
+ response_body: BatchBodyCap.cap(batch_item["response"]),
117
117
  status_code: 200,
118
118
  start_time: start_time,
119
119
  end_time: end_time,
@@ -5,6 +5,27 @@ require_relative "open_ai/batch_result_processor"
5
5
 
6
6
  module Coolhand
7
7
  module WebhookInterceptor
8
+ # Helpers that must never be routable as controller actions.
9
+ HELPER_METHODS = %w[intercept_batch_request webhook_secret process_event].freeze
10
+ private_constant :HELPER_METHODS
11
+
12
+ # `private` below only covers the definitions in this module. A host that
13
+ # re-defines `webhook_secret` or `process_event` as public would make them
14
+ # public again, and Rails would then count them as action methods. Filtering
15
+ # them out of `action_methods` keeps them unroutable either way, without this
16
+ # gem depending on Rails (`super` is Rails' own `action_methods`).
17
+ module ClassMethods
18
+ def action_methods
19
+ super - HELPER_METHODS
20
+ end
21
+ end
22
+
23
+ def self.included(base)
24
+ base.extend(ClassMethods)
25
+ end
26
+
27
+ private
28
+
8
29
  def intercept_batch_request
9
30
  Rails.logger.info("[Interceptor] #{controller_name}##{action_name}")
10
31
 
@@ -27,6 +48,8 @@ module Coolhand
27
48
  # let the controller action run for a request whose webhook
28
49
  # signature was never confirmed valid.
29
50
  Rails.logger.error("[Interceptor] Failed to intercept batch request: #{e.message}")
51
+ # Give the webhook-id back so the sender's retry isn't rejected as a replay.
52
+ @validator&.release_claim!
30
53
  head :unauthorized
31
54
  false
32
55
  end
data/lib/coolhand.rb CHANGED
@@ -14,14 +14,18 @@ require_relative "coolhand/declared_context"
14
14
  require_relative "coolhand/declarable"
15
15
  require_relative "coolhand/rails_auto_declare"
16
16
  require_relative "coolhand/log_queue"
17
+ require_relative "coolhand/body_redactor"
18
+ require_relative "coolhand/string_body_redactor"
17
19
  require_relative "coolhand/base_interceptor"
18
20
  require_relative "coolhand/net_http_interceptor"
21
+ require_relative "coolhand/batch_body_cap"
19
22
  require_relative "coolhand/api_service"
20
23
  require_relative "coolhand/logger_service"
21
24
  require_relative "coolhand/feedback_service"
22
25
  require_relative "coolhand/template_service"
23
26
  require_relative "coolhand/optimization_feedback_link_service"
24
27
  require_relative "coolhand/workload_service"
28
+ require_relative "coolhand/help_center_service"
25
29
  require_relative "coolhand/log_service"
26
30
  require_relative "coolhand/webhook_interceptor"
27
31
  # Only when Rails is already loaded; the gem has no Rails dependency.
@@ -147,6 +151,12 @@ module Coolhand
147
151
  LogService.new
148
152
  end
149
153
 
154
+ # Creates a new HelpCenterService, for reading the Coolhand help center. Needs no API key; a
155
+ # configured one is sent and lifts the anonymous rate limit.
156
+ def help_center_service
157
+ HelpCenterService.new
158
+ end
159
+
150
160
  # Creates a new OptimizationFeedbackLinkService instance, for linking feedback to an
151
161
  # optimization as evidence. Needs the private API key.
152
162
  def optimization_feedback_link_service