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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +28 -7
- data/docs/anthropic.md +28 -83
- data/docs/configuration.md +31 -5
- data/docs/elevenlabs.md +1 -1
- data/docs/help-center.md +120 -0
- 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/help_center_service.rb +69 -0
- data/lib/coolhand/logger_service.rb +55 -6
- 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/read_requests.rb +11 -2
- 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 +10 -0
- metadata +9 -2
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/coolhand/version.rb
CHANGED
|
@@ -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
|