coolhand 0.2.0 → 0.3.0
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/.rubocop.yml +12 -0
- data/CHANGELOG.md +53 -1
- data/CLAUDE.md +34 -0
- data/README.md +147 -27
- data/coolhand-ruby.gemspec +46 -0
- data/docs/anthropic.md +11 -11
- data/docs/elevenlabs.md +6 -4
- data/lib/coolhand/api_service.rb +264 -0
- data/lib/coolhand/base_interceptor.rb +213 -0
- data/lib/coolhand/collector.rb +19 -0
- data/lib/coolhand/configuration.rb +84 -0
- data/lib/coolhand/default_exclude_api_patterns.yml +9 -0
- data/lib/coolhand/default_intercept_addresses.yml +15 -0
- data/lib/coolhand/feedback_service.rb +15 -0
- data/lib/coolhand/logger_service.rb +112 -0
- data/lib/coolhand/net_http_interceptor.rb +163 -0
- data/lib/coolhand/open_ai/batch_result_processor.rb +139 -0
- data/lib/coolhand/open_ai/webhook_validator.rb +127 -0
- data/lib/coolhand/{ruby/version.rb → version.rb} +1 -3
- data/lib/coolhand/vertex/batch_result_processor.rb +84 -0
- data/lib/coolhand/webhook_interceptor.rb +39 -0
- data/lib/coolhand.rb +109 -2
- metadata +35 -18
- data/lib/coolhand/ruby/anthropic_interceptor.rb +0 -300
- data/lib/coolhand/ruby/api_service.rb +0 -226
- data/lib/coolhand/ruby/base_interceptor.rb +0 -148
- data/lib/coolhand/ruby/collector.rb +0 -21
- data/lib/coolhand/ruby/configuration.rb +0 -38
- data/lib/coolhand/ruby/faraday_interceptor.rb +0 -129
- data/lib/coolhand/ruby/feedback_service.rb +0 -17
- data/lib/coolhand/ruby/logger_service.rb +0 -114
- data/lib/coolhand/ruby.rb +0 -121
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "api_service"
|
|
4
|
+
|
|
5
|
+
module Coolhand
|
|
6
|
+
class LoggerService < ApiService
|
|
7
|
+
def initialize
|
|
8
|
+
super("v2/llm_request_logs")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def log_to_api(captured_data)
|
|
12
|
+
create_log(captured_data, "auto-monitor")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Helper method for forwarding webhook data to Coolhand
|
|
16
|
+
def forward_webhook(webhook_body:, source:, event_type: nil, headers: {}, **options)
|
|
17
|
+
# Validate required parameters
|
|
18
|
+
unless Coolhand.required_field?(webhook_body)
|
|
19
|
+
error_msg = "webhook_body is required and cannot be nil or empty"
|
|
20
|
+
if Coolhand.configuration.silent
|
|
21
|
+
puts "COOLHAND WARNING: #{error_msg}"
|
|
22
|
+
return false
|
|
23
|
+
else
|
|
24
|
+
raise ArgumentError, error_msg
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
unless Coolhand.required_field?(source)
|
|
29
|
+
error_msg = "source is required and cannot be nil or empty"
|
|
30
|
+
if Coolhand.configuration.silent
|
|
31
|
+
puts "COOLHAND WARNING: #{error_msg}"
|
|
32
|
+
return false
|
|
33
|
+
else
|
|
34
|
+
raise ArgumentError, error_msg
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Auto-generate required fields unless provided
|
|
39
|
+
webhook_data = {
|
|
40
|
+
id: options[:id] || SecureRandom.uuid,
|
|
41
|
+
timestamp: options[:timestamp] || Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ"),
|
|
42
|
+
method: options[:method] || "POST",
|
|
43
|
+
url: options[:url] || build_webhook_url(source, event_type),
|
|
44
|
+
headers: sanitize_headers(headers),
|
|
45
|
+
request_body: clean_webhook_body(webhook_body, source),
|
|
46
|
+
response_body: options[:response_body],
|
|
47
|
+
response_headers: options[:response_headers],
|
|
48
|
+
status_code: options[:status_code] || 200,
|
|
49
|
+
source: "#{source}_webhook"
|
|
50
|
+
}.merge(options.slice(:metadata, :conversation_id, :agent_id))
|
|
51
|
+
|
|
52
|
+
# Send to API asynchronously
|
|
53
|
+
log_to_api(webhook_data)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def build_webhook_url(source, event_type)
|
|
59
|
+
base = "webhook://#{source}"
|
|
60
|
+
event_type ? "#{base}/#{event_type}" : base
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def sanitize_headers(headers)
|
|
64
|
+
return {} if headers.nil?
|
|
65
|
+
return {} if headers.respond_to?(:empty?) && headers.empty?
|
|
66
|
+
return {} unless headers.respond_to?(:each)
|
|
67
|
+
|
|
68
|
+
# Handle Rails request headers or plain hash
|
|
69
|
+
clean_headers = {}
|
|
70
|
+
headers.each do |key, value|
|
|
71
|
+
next unless key && value
|
|
72
|
+
|
|
73
|
+
# Convert Rails HTTP_ prefix headers
|
|
74
|
+
clean_key = key.to_s.gsub(/^HTTP_/, "").tr("_", "-").downcase
|
|
75
|
+
|
|
76
|
+
# Redact sensitive headers
|
|
77
|
+
clean_value = clean_key.match?(/key|token|secret|authorization|signature/i) ? "[REDACTED]" : value.to_s
|
|
78
|
+
clean_headers[clean_key] = clean_value
|
|
79
|
+
end
|
|
80
|
+
clean_headers
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def clean_webhook_body(body, source)
|
|
84
|
+
# Service-specific binary field filters
|
|
85
|
+
binary_fields = case source.to_s.downcase
|
|
86
|
+
when "elevenlabs"
|
|
87
|
+
%w[full_audio audio audio_data raw_audio audio_base64 voice_sample audio_url]
|
|
88
|
+
when "twilio"
|
|
89
|
+
%w[recording_url media_url]
|
|
90
|
+
else
|
|
91
|
+
%w[audio_data image_data file_content binary_content]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
remove_binary_fields(body, binary_fields)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def remove_binary_fields(obj, fields_to_remove)
|
|
98
|
+
case obj
|
|
99
|
+
when Hash
|
|
100
|
+
obj.each_with_object({}) do |(key, value), cleaned|
|
|
101
|
+
next if fields_to_remove.any? { |field| key.to_s.downcase.include?(field) }
|
|
102
|
+
|
|
103
|
+
cleaned[key] = remove_binary_fields(value, fields_to_remove)
|
|
104
|
+
end
|
|
105
|
+
when Array
|
|
106
|
+
obj.map { |item| remove_binary_fields(item, fields_to_remove) }
|
|
107
|
+
else
|
|
108
|
+
obj
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
module NetHttpInterceptor
|
|
5
|
+
include BaseInterceptor
|
|
6
|
+
|
|
7
|
+
# Response streaming interceptor nested under NetHttpInterceptor
|
|
8
|
+
module ResponseInterceptor
|
|
9
|
+
def read_body(dest = nil, &block)
|
|
10
|
+
return super unless block
|
|
11
|
+
|
|
12
|
+
super do |chunk|
|
|
13
|
+
Thread.current[:coolhand_stream_buffer] ||= +""
|
|
14
|
+
Thread.current[:coolhand_stream_buffer] << chunk
|
|
15
|
+
yield(chunk)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.patch!
|
|
21
|
+
return if @patched
|
|
22
|
+
|
|
23
|
+
Net::HTTP.prepend(self)
|
|
24
|
+
Net::HTTPResponse.prepend(ResponseInterceptor)
|
|
25
|
+
|
|
26
|
+
@patched = true
|
|
27
|
+
Coolhand.log "🔗 Net::HTTP interceptor patched"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.unpatch!
|
|
31
|
+
# NOTE: With prepend, there's no clean way to unpatch
|
|
32
|
+
# We'll mark it as unpatched so it can be re-patched
|
|
33
|
+
@patched = false
|
|
34
|
+
Coolhand.log "🔌 Faraday monitoring disabled ..."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.patched?
|
|
38
|
+
@patched
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def request(req, body = nil, &block)
|
|
42
|
+
return super unless NetHttpInterceptor.patched?
|
|
43
|
+
|
|
44
|
+
active = (Thread.current[:coolhand_active_requests] ||= {}.compare_by_identity)
|
|
45
|
+
return super if active.key?(self)
|
|
46
|
+
|
|
47
|
+
url = build_url_for_request(self, req)
|
|
48
|
+
return super unless intercept?(url)
|
|
49
|
+
return super unless should_capture?
|
|
50
|
+
|
|
51
|
+
# Capture body before setting the guard — if this raises we skip logging cleanly
|
|
52
|
+
# and the guard is never set, so there is no leak.
|
|
53
|
+
captured_body = capture_request_body(req, body)
|
|
54
|
+
|
|
55
|
+
active[self] = true
|
|
56
|
+
start_time = Time.now
|
|
57
|
+
request_id = SecureRandom.uuid
|
|
58
|
+
response = nil
|
|
59
|
+
status_code = nil
|
|
60
|
+
response_body = nil
|
|
61
|
+
|
|
62
|
+
Thread.current[:coolhand_stream_buffer] = nil
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
response = super
|
|
66
|
+
body_content = Thread.current[:coolhand_stream_buffer] || response&.body
|
|
67
|
+
body_content = body_content.dup.force_encoding("UTF-8") if body_content.is_a?(String)
|
|
68
|
+
status_code = response.respond_to?(:code) ? response.code.to_i : nil
|
|
69
|
+
response_body = parse_json(body_content)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
status_code = extract_status_from_exception(e)
|
|
72
|
+
response_body = { "error" => { "class" => e.class.name, "message" => e.message } }
|
|
73
|
+
raise
|
|
74
|
+
ensure
|
|
75
|
+
active.delete(self)
|
|
76
|
+
Thread.current[:coolhand_stream_buffer] = nil
|
|
77
|
+
end_time = Time.now
|
|
78
|
+
duration_ms = ((end_time - start_time) * 1000).round(2)
|
|
79
|
+
|
|
80
|
+
send_complete_request_log(
|
|
81
|
+
request_id: request_id,
|
|
82
|
+
method: req.method,
|
|
83
|
+
url: url,
|
|
84
|
+
request_headers: sanitize_headers(req),
|
|
85
|
+
request_body: captured_body,
|
|
86
|
+
response_headers: sanitize_headers(response),
|
|
87
|
+
response_body: response_body,
|
|
88
|
+
status_code: status_code,
|
|
89
|
+
start_time: start_time,
|
|
90
|
+
end_time: end_time,
|
|
91
|
+
duration_ms: duration_ms,
|
|
92
|
+
is_streaming: !!block
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
response
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def should_capture?
|
|
102
|
+
return true if Coolhand.configuration.debug_mode
|
|
103
|
+
|
|
104
|
+
override = Thread.current[:coolhand_capture_override]
|
|
105
|
+
return override unless override.nil?
|
|
106
|
+
|
|
107
|
+
Coolhand.configuration.capture
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def capture_request_body(req, body)
|
|
111
|
+
return parse_json(body) if body
|
|
112
|
+
return parse_json(req.body) if req.body
|
|
113
|
+
|
|
114
|
+
if req.respond_to?(:body_stream) && req.body_stream
|
|
115
|
+
content = req.body_stream.read
|
|
116
|
+
req.body_stream = StringIO.new(content)
|
|
117
|
+
return parse_json(content)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def extract_status_from_exception(e)
|
|
124
|
+
return e.status if e.respond_to?(:status) && e.status.is_a?(Integer)
|
|
125
|
+
return e.response.status if e.respond_to?(:response) && e.response.respond_to?(:status)
|
|
126
|
+
|
|
127
|
+
match = e.message.to_s.match(/status[=:\s]+(\d{3})/)
|
|
128
|
+
match ? match[1].to_i : nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def intercept?(url)
|
|
132
|
+
return false unless url && Coolhand.configuration.respond_to?(:intercept_addresses)
|
|
133
|
+
return false if excluded_by_pattern?(url)
|
|
134
|
+
|
|
135
|
+
Coolhand.configuration.intercept_addresses.any? { |a| url.include?(a) }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def excluded_by_pattern?(url)
|
|
139
|
+
patterns = Coolhand.configuration.exclude_api_patterns
|
|
140
|
+
return false if patterns.nil? || patterns.empty?
|
|
141
|
+
|
|
142
|
+
matched = patterns.find { |pattern| url.include?(pattern) }
|
|
143
|
+
if matched && Coolhand.configuration.debug_mode
|
|
144
|
+
Coolhand.log "🚫 Skipping capture for #{url} (matched exclude_api_pattern: \"#{matched}\")"
|
|
145
|
+
end
|
|
146
|
+
!!matched
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def build_url_for_request(http, req)
|
|
150
|
+
return req.path if %r{\Ahttps?://}.match?(req.path)
|
|
151
|
+
|
|
152
|
+
scheme = http.use_ssl? ? "https" : "http"
|
|
153
|
+
host = http.address
|
|
154
|
+
port = http.port
|
|
155
|
+
default = http.use_ssl? ? 443 : 80
|
|
156
|
+
|
|
157
|
+
url = "#{scheme}://#{host}"
|
|
158
|
+
url << ":#{port}" if port != default
|
|
159
|
+
url << req.path
|
|
160
|
+
url
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
module OpenAi
|
|
5
|
+
class BatchResultProcessor
|
|
6
|
+
attr_reader :event_data
|
|
7
|
+
|
|
8
|
+
def initialize(event_data:)
|
|
9
|
+
@event_data = event_data
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
Rails.logger.info("[Interceptor] BatchResultProcessor: #{event_data}")
|
|
14
|
+
|
|
15
|
+
case batch_info["status"]
|
|
16
|
+
when "completed"
|
|
17
|
+
process_completed_batch
|
|
18
|
+
when "failed", "expired", "cancelled"
|
|
19
|
+
handle_failed_batch
|
|
20
|
+
when "in_progress", "validating", "finalizing"
|
|
21
|
+
Rails.logger.info("[Interceptor] OpenAI batch #{event_data} still processing")
|
|
22
|
+
else
|
|
23
|
+
Rails.logger.warn("[Interceptor] Unknown batch status: #{batch_info['status']} for batch #{event_data}")
|
|
24
|
+
end
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
Rails.logger.error("[Interceptor] Failed to process OpenAI batch results for #{event_data}: #{e.message}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def process_completed_batch
|
|
32
|
+
input_file_id = batch_info["input_file_id"]
|
|
33
|
+
return unless input_file_id
|
|
34
|
+
|
|
35
|
+
output_file_id = batch_info["output_file_id"]
|
|
36
|
+
return unless output_file_id
|
|
37
|
+
|
|
38
|
+
# Download and process results
|
|
39
|
+
batch_request_items = download_batch_results(input_file_id)
|
|
40
|
+
|
|
41
|
+
# Download and process results
|
|
42
|
+
batch_response_items = download_batch_results(output_file_id)
|
|
43
|
+
|
|
44
|
+
batch_response_items.each do |response_item|
|
|
45
|
+
request_item = batch_request_items.detect { |item| item["custom_id"] == response_item["custom_id"] }
|
|
46
|
+
|
|
47
|
+
next unless request_item
|
|
48
|
+
|
|
49
|
+
send_complete_request_log(request_id: response_item["response"]["request_id"],
|
|
50
|
+
method: request_item["method"],
|
|
51
|
+
url: request_item["url"],
|
|
52
|
+
request_body: request_item["body"],
|
|
53
|
+
response_body: response_item["response"]["body"],
|
|
54
|
+
status_code: response_item["response"]["status_code"],
|
|
55
|
+
start_time: batch_info["in_progress_at"].to_i,
|
|
56
|
+
end_time: batch_info["completed_at"].to_i)
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
Rails.logger.error("[Interceptor] Failed to send request log: #{e.message}")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
Rails.logger.info("[Interceptor] Successfully processed OpenAI batch #{batch_info['id']}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def download_batch_results(file_id)
|
|
65
|
+
file_content = client.files.content(id: file_id)
|
|
66
|
+
|
|
67
|
+
# Handle both string and array responses from the OpenAI client
|
|
68
|
+
if file_content.is_a?(Array)
|
|
69
|
+
# Client already parsed the JSONL into an array
|
|
70
|
+
file_content
|
|
71
|
+
else
|
|
72
|
+
# Parse JSONL response manually
|
|
73
|
+
file_content.split("\n").filter_map do |line|
|
|
74
|
+
line.strip!
|
|
75
|
+
next if line.empty?
|
|
76
|
+
|
|
77
|
+
JSON.parse(line)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
rescue JSON::ParserError => e
|
|
81
|
+
Rails.logger.error("[Interceptor] Failed to parse OpenAI batch results: #{e.message}")
|
|
82
|
+
[]
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
Rails.logger.error("[Interceptor] Failed to download OpenAI batch results: #{e.message}")
|
|
85
|
+
[]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def send_complete_request_log(request_id:, method:, url:, request_body:, response_body:, status_code:,
|
|
89
|
+
start_time:, end_time:)
|
|
90
|
+
timestamp = Time.at(start_time).iso8601
|
|
91
|
+
completed_at = Time.at(end_time).iso8601
|
|
92
|
+
duration_ms = ((end_time - start_time) * 1000).to_i
|
|
93
|
+
|
|
94
|
+
request_data = {
|
|
95
|
+
raw_request: {
|
|
96
|
+
id: request_id,
|
|
97
|
+
timestamp: timestamp,
|
|
98
|
+
method: method.to_s.downcase,
|
|
99
|
+
url: url,
|
|
100
|
+
headers: {},
|
|
101
|
+
request_body: request_body,
|
|
102
|
+
response_headers: {},
|
|
103
|
+
response_body: response_body,
|
|
104
|
+
status_code: status_code,
|
|
105
|
+
duration_ms: duration_ms,
|
|
106
|
+
completed_at: completed_at,
|
|
107
|
+
is_streaming: false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
api_service = Coolhand::ApiService.new
|
|
112
|
+
api_service.send_llm_request_log(request_data)
|
|
113
|
+
|
|
114
|
+
Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
Coolhand.log "❌ Error sending complete request log: #{e.message}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def batch_info
|
|
120
|
+
@batch_info ||= client.batches.retrieve(id: event_data["id"])
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def client
|
|
124
|
+
@client ||= begin
|
|
125
|
+
require "openai"
|
|
126
|
+
OpenAI::Client.new
|
|
127
|
+
rescue LoadError => e
|
|
128
|
+
raise LoadError, "The 'openai' gem is required to process OpenAI batches. " \
|
|
129
|
+
"Install it with: gem 'openai'\n\nOriginal error: #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# TODO: implement API to handle failed batch results and display errors on dashboard page
|
|
134
|
+
def handle_failed_batch
|
|
135
|
+
Rails.logger.error("[Interceptor] OpenAI batch #{batch_info['id']} failed: #{batch_info['errors']}")
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
module OpenAi
|
|
5
|
+
class WebhookValidator
|
|
6
|
+
attr_reader :request, :errors, :payload, :webhook_secret
|
|
7
|
+
|
|
8
|
+
def initialize(request, webhook_secret)
|
|
9
|
+
@request = request
|
|
10
|
+
@errors = []
|
|
11
|
+
@webhook_secret = webhook_secret
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def valid?
|
|
15
|
+
@errors.clear
|
|
16
|
+
@payload = request.raw_post || request.body.read
|
|
17
|
+
|
|
18
|
+
return false unless payload_valid?
|
|
19
|
+
return validate_in_non_production_env unless webhook_secret
|
|
20
|
+
|
|
21
|
+
secret_bytes = extract_secret_bytes
|
|
22
|
+
webhook_signature, webhook_timestamp, webhook_id = extract_webhook_headers
|
|
23
|
+
|
|
24
|
+
return validate_headers_in_non_production_env unless webhook_signature && webhook_timestamp
|
|
25
|
+
|
|
26
|
+
verify_signature(webhook_signature, webhook_timestamp, webhook_id, secret_bytes)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def error_message
|
|
30
|
+
@errors.join(", ")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def payload_valid?
|
|
36
|
+
return true if @payload
|
|
37
|
+
|
|
38
|
+
if should_enforce_strict_validation?
|
|
39
|
+
@errors << "Empty webhook payload - rejecting webhook in production/staging"
|
|
40
|
+
Rails.logger.error(@errors.last)
|
|
41
|
+
false
|
|
42
|
+
else
|
|
43
|
+
Rails.logger.warn("Empty webhook payload - allowing in #{Rails.env} environment")
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_in_non_production_env
|
|
49
|
+
if should_enforce_strict_validation?
|
|
50
|
+
@errors << "OpenAI webhook secret not configured - rejecting webhook in production/staging"
|
|
51
|
+
Rails.logger.error(@errors.last)
|
|
52
|
+
false
|
|
53
|
+
else
|
|
54
|
+
Rails.logger.warn(
|
|
55
|
+
"Webhook Secret is not configured - skipping signature verification in #{Rails.env}"
|
|
56
|
+
)
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def extract_secret_bytes
|
|
62
|
+
if @webhook_secret.start_with?("whsec_")
|
|
63
|
+
webhook_secret_key = @webhook_secret[6..]
|
|
64
|
+
Base64.strict_decode64(webhook_secret_key)
|
|
65
|
+
else
|
|
66
|
+
@webhook_secret
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def extract_webhook_headers
|
|
71
|
+
webhook_signature = request.headers["webhook-signature"] || request.headers["openai-signature"]
|
|
72
|
+
webhook_timestamp = request.headers["webhook-timestamp"] || request.headers["openai-timestamp"]
|
|
73
|
+
webhook_id = request.headers["webhook-id"] || request.headers["openai-id"]
|
|
74
|
+
[webhook_signature, webhook_timestamp, webhook_id]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_headers_in_non_production_env
|
|
78
|
+
if should_enforce_strict_validation?
|
|
79
|
+
@errors << "Missing OpenAI webhook signature or timestamp headers - " \
|
|
80
|
+
"rejecting webhook in production/staging"
|
|
81
|
+
Rails.logger.error(@errors.last)
|
|
82
|
+
false
|
|
83
|
+
else
|
|
84
|
+
Rails.logger.warn("Missing OpenAI webhook headers - skipping verification in #{Rails.env}")
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def verify_signature(webhook_signature, webhook_timestamp, webhook_id, secret_bytes)
|
|
90
|
+
signed_payload = "#{webhook_id}.#{webhook_timestamp}.#{@payload}"
|
|
91
|
+
expected_signature = calculate_expected_signature(secret_bytes, signed_payload)
|
|
92
|
+
|
|
93
|
+
signature_valid = webhook_signature.start_with?("v1,") &&
|
|
94
|
+
secure_compare(webhook_signature[3..], expected_signature)
|
|
95
|
+
if signature_valid
|
|
96
|
+
true
|
|
97
|
+
else
|
|
98
|
+
@errors << "OpenAI webhook signature verification failed"
|
|
99
|
+
Rails.logger.error(@errors.last)
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def secure_compare(a, b)
|
|
105
|
+
return false unless a.bytesize == b.bytesize
|
|
106
|
+
|
|
107
|
+
result = 0
|
|
108
|
+
a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
|
|
109
|
+
result.zero?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def calculate_expected_signature(secret_bytes, signed_payload)
|
|
113
|
+
Base64.strict_encode64(
|
|
114
|
+
OpenSSL::HMAC.digest(
|
|
115
|
+
OpenSSL::Digest.new("sha256"),
|
|
116
|
+
secret_bytes,
|
|
117
|
+
signed_payload
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def should_enforce_strict_validation?
|
|
123
|
+
["production", "staging"].include?(Rails.env)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
module Vertex
|
|
5
|
+
class BatchResultProcessor
|
|
6
|
+
attr_reader :batch_info
|
|
7
|
+
|
|
8
|
+
def initialize(batch_info:)
|
|
9
|
+
@batch_info = batch_info
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(batch_results = [])
|
|
13
|
+
Rails.logger.info("[Interceptor] BatchResultProcessor: #{batch_info}")
|
|
14
|
+
|
|
15
|
+
case batch_info["state"]
|
|
16
|
+
when "JOB_STATE_PENDING", "JOB_STATE_RUNNING", "JOB_STATE_QUEUED"
|
|
17
|
+
Rails.logger.info("[Interceptor] Vertex batch #{batch_info} still processing")
|
|
18
|
+
when "JOB_STATE_SUCCEEDED"
|
|
19
|
+
batch_results.each { |batch_item| process_completed_batch(batch_item) }
|
|
20
|
+
when "JOB_STATE_FAILED"
|
|
21
|
+
handle_failed_batch
|
|
22
|
+
else
|
|
23
|
+
Rails.logger.warn("[Interceptor] Unknown batch status: #{batch_info['state']} for batch #{batch_info}")
|
|
24
|
+
end
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
Rails.logger.error("[Interceptor] Failed to process Vertex batch results for #{batch_info}: #{e.message}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def process_completed_batch(batch_item)
|
|
32
|
+
send_complete_request_log(request_id: SecureRandom.hex(16),
|
|
33
|
+
method: "POST",
|
|
34
|
+
url: batch_info["name"],
|
|
35
|
+
request_body: batch_item["request"],
|
|
36
|
+
response_body: batch_item["response"],
|
|
37
|
+
status_code: 200,
|
|
38
|
+
start_time: batch_info["startTime"],
|
|
39
|
+
end_time: batch_info["endTime"])
|
|
40
|
+
|
|
41
|
+
Rails.logger.info("[Interceptor] Successfully processed Vertex batch #{batch_info['displayName']}")
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
Rails.logger.error("[Interceptor] Failed to send request log: #{e.message}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def send_complete_request_log(request_id:, method:, url:, request_body:, response_body:, status_code:,
|
|
47
|
+
start_time:, end_time:)
|
|
48
|
+
start_time = Time.iso8601(start_time)
|
|
49
|
+
end_time = Time.iso8601(end_time)
|
|
50
|
+
duration_ms = ((end_time - start_time) * 1000).to_i
|
|
51
|
+
|
|
52
|
+
request_data = {
|
|
53
|
+
raw_request: {
|
|
54
|
+
id: request_id,
|
|
55
|
+
timestamp: start_time,
|
|
56
|
+
method: method.to_s.downcase,
|
|
57
|
+
url: url,
|
|
58
|
+
headers: {},
|
|
59
|
+
request_body: request_body,
|
|
60
|
+
response_headers: {},
|
|
61
|
+
response_body: response_body,
|
|
62
|
+
status_code: status_code,
|
|
63
|
+
duration_ms: duration_ms,
|
|
64
|
+
completed_at: end_time,
|
|
65
|
+
is_streaming: false
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
api_service = Coolhand::ApiService.new
|
|
70
|
+
api_service.send_llm_request_log(request_data)
|
|
71
|
+
|
|
72
|
+
Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
Coolhand.log "❌ Error sending complete request log: #{e.message}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# TODO: implement API to handle failed batch results and display errors on dashboard page
|
|
78
|
+
def handle_failed_batch
|
|
79
|
+
Rails.logger.error("[Interceptor] Vertex batch for #{batch_info['displayName']} " \
|
|
80
|
+
"failed: #{batch_info['error']['message']}")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
module WebhookInterceptor
|
|
5
|
+
def intercept_batch_request
|
|
6
|
+
Rails.logger.info("[Interceptor] #{controller_name}##{action_name}")
|
|
7
|
+
|
|
8
|
+
@validator = Coolhand::OpenAi::WebhookValidator.new(request, webhook_secret)
|
|
9
|
+
|
|
10
|
+
unless @validator.valid?
|
|
11
|
+
Rails.logger.info("[Interceptor] Webhook validated failed: #{@validator.error_message}")
|
|
12
|
+
head :unauthorized
|
|
13
|
+
return false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
payload = JSON.parse(@validator.payload)
|
|
17
|
+
|
|
18
|
+
process_event(payload)
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
Rails.logger.error("[Interceptor] Failed to intercept batch request: #{e.message}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def webhook_secret
|
|
24
|
+
raise NotImplementedError, "#{self.class} must implement #webhook_secret"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def process_event(payload)
|
|
28
|
+
event_type = payload["type"]
|
|
29
|
+
event_data = payload["data"]
|
|
30
|
+
|
|
31
|
+
case event_type
|
|
32
|
+
when "batch.completed", "batch.failed", "batch.expired", "batch.cancelled"
|
|
33
|
+
Coolhand::OpenAi::BatchResultProcessor.new(event_data: event_data).call
|
|
34
|
+
else
|
|
35
|
+
Rails.logger.info("[Interceptor] Unhandled OpenAI webhook event type: #{event_type}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|