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
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "net/http"
|
|
4
|
-
require "uri"
|
|
5
|
-
require "json"
|
|
6
|
-
require_relative "collector"
|
|
7
|
-
|
|
8
|
-
module Coolhand
|
|
9
|
-
module Ruby
|
|
10
|
-
class ApiService
|
|
11
|
-
BASE_URI = "https://coolhandlabs.com/api"
|
|
12
|
-
|
|
13
|
-
attr_reader :api_endpoint
|
|
14
|
-
|
|
15
|
-
def initialize(endpoint_path = nil)
|
|
16
|
-
@api_endpoint = endpoint_path ? "#{BASE_URI}/#{endpoint_path}" : BASE_URI
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def send_llm_request_log(request_data)
|
|
20
|
-
original_endpoint = @api_endpoint
|
|
21
|
-
@api_endpoint = "#{BASE_URI}/v2/llm_request_logs"
|
|
22
|
-
|
|
23
|
-
payload = {
|
|
24
|
-
llm_request_log: request_data.merge(
|
|
25
|
-
collector: Collector.get_collector_string
|
|
26
|
-
)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
result = send_request(payload, "✅ Successfully sent request metadata")
|
|
30
|
-
@api_endpoint = original_endpoint
|
|
31
|
-
result
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def configuration
|
|
35
|
-
Coolhand.configuration
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def api_key
|
|
39
|
-
configuration.api_key
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def silent
|
|
43
|
-
configuration.silent
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
protected
|
|
47
|
-
|
|
48
|
-
# Add collector field to the data being sent
|
|
49
|
-
def add_collector_to_data(data, collection_method = nil)
|
|
50
|
-
data.merge(collector: Collector.get_collector_string(collection_method))
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def create_request_options(_payload)
|
|
54
|
-
{
|
|
55
|
-
"Content-Type" => "application/json",
|
|
56
|
-
"X-API-Key" => api_key
|
|
57
|
-
}
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def send_request(payload, success_message)
|
|
61
|
-
uri = URI.parse(@api_endpoint)
|
|
62
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
63
|
-
http.use_ssl = (uri.scheme == "https")
|
|
64
|
-
|
|
65
|
-
request = Net::HTTP::Post.new(uri.request_uri)
|
|
66
|
-
headers = create_request_options(payload)
|
|
67
|
-
headers.each do |key, value|
|
|
68
|
-
# Ensure header values are UTF-8 encoded
|
|
69
|
-
encoded_value = value.is_a?(String) ? value.dup.force_encoding("UTF-8") : value
|
|
70
|
-
request[key] = encoded_value
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Clean payload and ensure UTF-8 encoding before JSON generation
|
|
74
|
-
cleaned_payload = sanitize_payload_for_json(payload)
|
|
75
|
-
json_body = JSON.generate(cleaned_payload)
|
|
76
|
-
|
|
77
|
-
# Ensure the request body is properly encoded as UTF-8
|
|
78
|
-
request.body = json_body.force_encoding("UTF-8")
|
|
79
|
-
|
|
80
|
-
begin
|
|
81
|
-
response = http.request(request)
|
|
82
|
-
|
|
83
|
-
if response.is_a?(Net::HTTPSuccess)
|
|
84
|
-
result = JSON.parse(response.body, symbolize_names: true)
|
|
85
|
-
log success_message
|
|
86
|
-
result
|
|
87
|
-
else
|
|
88
|
-
body = response.body.force_encoding("UTF-8") if response.body
|
|
89
|
-
puts "❌ Request failed: #{response.code} - #{body}"
|
|
90
|
-
nil
|
|
91
|
-
end
|
|
92
|
-
rescue StandardError => e
|
|
93
|
-
log "❌ Request error: #{e.message}"
|
|
94
|
-
nil
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def log(*args)
|
|
99
|
-
puts args.join(" ") unless silent
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def log_separator
|
|
103
|
-
log("═" * 60) unless silent
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def create_feedback(feedback, collection_method = nil)
|
|
107
|
-
feedback_with_collector = add_collector_to_data(feedback, collection_method)
|
|
108
|
-
|
|
109
|
-
payload = {
|
|
110
|
-
llm_request_log_feedback: feedback_with_collector
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
log_feedback_info(feedback)
|
|
114
|
-
|
|
115
|
-
result = send_request(
|
|
116
|
-
payload,
|
|
117
|
-
"✅ Successfully created feedback with ID: #{feedback[:llm_request_log_id] || 'N/A'}"
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
log_separator
|
|
121
|
-
result
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def create_log(captured_data, collection_method = nil)
|
|
125
|
-
raw_request_with_collector = add_collector_to_data({ raw_request: captured_data }, collection_method)
|
|
126
|
-
|
|
127
|
-
payload = {
|
|
128
|
-
llm_request_log: raw_request_with_collector
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
log_request_info(captured_data)
|
|
132
|
-
|
|
133
|
-
result = send_request(
|
|
134
|
-
payload,
|
|
135
|
-
"✅ Successfully logged to API"
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
puts "✅ Successfully logged to API with ID: #{result[:id]}" if result && !silent
|
|
139
|
-
|
|
140
|
-
log_separator
|
|
141
|
-
result
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
# Filter list of known binary/problematic field names by service
|
|
145
|
-
BINARY_DATA_FILTERS = {
|
|
146
|
-
# ElevenLabs fields that contain binary audio data
|
|
147
|
-
elevenlabs: %w[
|
|
148
|
-
full_audio
|
|
149
|
-
audio
|
|
150
|
-
audio_data
|
|
151
|
-
raw_audio
|
|
152
|
-
audio_base64
|
|
153
|
-
voice_sample
|
|
154
|
-
audio_url
|
|
155
|
-
],
|
|
156
|
-
# OpenAI fields that might contain binary data
|
|
157
|
-
openai: %w[
|
|
158
|
-
file_content
|
|
159
|
-
audio_data
|
|
160
|
-
image_data
|
|
161
|
-
binary_content
|
|
162
|
-
]
|
|
163
|
-
}.freeze
|
|
164
|
-
|
|
165
|
-
private
|
|
166
|
-
|
|
167
|
-
# Get all filtered field names as a flat array
|
|
168
|
-
def filtered_field_names
|
|
169
|
-
@filtered_field_names ||= BINARY_DATA_FILTERS.values.flatten.map(&:downcase)
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
# Recursively sanitize payload to remove known problematic fields
|
|
173
|
-
def sanitize_payload_for_json(obj)
|
|
174
|
-
case obj
|
|
175
|
-
when Hash
|
|
176
|
-
obj.each_with_object({}) do |(key, value), sanitized|
|
|
177
|
-
key_str = key.to_s.downcase
|
|
178
|
-
|
|
179
|
-
# Skip if key matches any filtered field name
|
|
180
|
-
next if filtered_field_names.any? { |filter| key_str.include?(filter) }
|
|
181
|
-
|
|
182
|
-
sanitized[key] = sanitize_payload_for_json(value)
|
|
183
|
-
end
|
|
184
|
-
when Array
|
|
185
|
-
obj.map { |item| sanitize_payload_for_json(item) }
|
|
186
|
-
else
|
|
187
|
-
obj
|
|
188
|
-
end
|
|
189
|
-
rescue StandardError => e
|
|
190
|
-
log "⚠️ Warning: Error sanitizing payload: #{e.message}"
|
|
191
|
-
obj
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
def log_feedback_info(feedback)
|
|
195
|
-
return if silent
|
|
196
|
-
|
|
197
|
-
# Log the appropriate identifier based on what was provided
|
|
198
|
-
if feedback[:llm_request_log_id]
|
|
199
|
-
puts "\n📝 CREATING FEEDBACK for LLM Request Log ID: #{feedback[:llm_request_log_id]}"
|
|
200
|
-
elsif feedback[:llm_provider_unique_id]
|
|
201
|
-
puts "\n📝 CREATING FEEDBACK for Provider Unique ID: #{feedback[:llm_provider_unique_id]}"
|
|
202
|
-
else
|
|
203
|
-
puts "\n📝 CREATING FEEDBACK"
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
puts "👍/👎 Like: #{feedback[:like]}"
|
|
207
|
-
|
|
208
|
-
if feedback[:explanation]
|
|
209
|
-
explanation = feedback[:explanation]
|
|
210
|
-
truncated = explanation.length > 100 ? "#{explanation[0..99]}..." : explanation
|
|
211
|
-
puts "💭 Explanation: #{truncated}"
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
puts "📤 Sending to: #{@api_endpoint}"
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def log_request_info(captured_data)
|
|
218
|
-
return if silent
|
|
219
|
-
|
|
220
|
-
puts "\n🎉 LOGGING OpenAI API Call #{@api_endpoint}"
|
|
221
|
-
puts captured_data
|
|
222
|
-
puts "📤 Sending to: #{@api_endpoint}"
|
|
223
|
-
end
|
|
224
|
-
end
|
|
225
|
-
end
|
|
226
|
-
end
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "securerandom"
|
|
4
|
-
require "json"
|
|
5
|
-
|
|
6
|
-
module Coolhand
|
|
7
|
-
module Ruby
|
|
8
|
-
# Base module with common functionality for all interceptors
|
|
9
|
-
module BaseInterceptor
|
|
10
|
-
module_function
|
|
11
|
-
|
|
12
|
-
def extract_response_data(response)
|
|
13
|
-
case response
|
|
14
|
-
when Hash
|
|
15
|
-
response
|
|
16
|
-
when Struct
|
|
17
|
-
response.to_h
|
|
18
|
-
else
|
|
19
|
-
# Handle streaming responses - these are often enumerator objects
|
|
20
|
-
# that can't be serialized directly
|
|
21
|
-
if response.class.name.include?("Stream") || response.respond_to?(:each)
|
|
22
|
-
{
|
|
23
|
-
response_type: "streaming",
|
|
24
|
-
class: response.class.name,
|
|
25
|
-
note: "Streaming response - content captured during enumeration"
|
|
26
|
-
}
|
|
27
|
-
elsif response.respond_to?(:to_h)
|
|
28
|
-
begin
|
|
29
|
-
response.to_h
|
|
30
|
-
rescue StandardError => e
|
|
31
|
-
{
|
|
32
|
-
serialization_error: e.message,
|
|
33
|
-
class: response.class.name,
|
|
34
|
-
raw_response: response.to_s
|
|
35
|
-
}
|
|
36
|
-
end
|
|
37
|
-
else
|
|
38
|
-
# Extract content and token usage information
|
|
39
|
-
response_data = {}
|
|
40
|
-
|
|
41
|
-
# Get content
|
|
42
|
-
response_data[:content] = response.content if response.respond_to?(:content)
|
|
43
|
-
|
|
44
|
-
# Extract token usage information
|
|
45
|
-
response_data[:usage] = extract_usage_metadata(response.usage) if response.respond_to?(:usage)
|
|
46
|
-
|
|
47
|
-
# Extract model information
|
|
48
|
-
response_data[:model] = response.model if response.respond_to?(:model)
|
|
49
|
-
|
|
50
|
-
# Extract role information
|
|
51
|
-
response_data[:role] = response.role if response.respond_to?(:role)
|
|
52
|
-
|
|
53
|
-
# Extract ID if available
|
|
54
|
-
response_data[:id] = response.id if response.respond_to?(:id)
|
|
55
|
-
|
|
56
|
-
# Extract stop reason if available
|
|
57
|
-
response_data[:stop_reason] = response.stop_reason if response.respond_to?(:stop_reason)
|
|
58
|
-
|
|
59
|
-
# Add class info for debugging
|
|
60
|
-
response_data[:class] = response.class.name
|
|
61
|
-
|
|
62
|
-
response_data.empty? ? { raw_response: response.to_s, class: response.class.name } : response_data
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def extract_usage_metadata(usage)
|
|
68
|
-
if usage.respond_to?(:to_h)
|
|
69
|
-
usage.to_h
|
|
70
|
-
elsif usage.is_a?(Hash)
|
|
71
|
-
usage
|
|
72
|
-
else
|
|
73
|
-
# Extract individual usage fields
|
|
74
|
-
usage_data = {}
|
|
75
|
-
usage_data[:input_tokens] = usage.input_tokens if usage.respond_to?(:input_tokens)
|
|
76
|
-
usage_data[:output_tokens] = usage.output_tokens if usage.respond_to?(:output_tokens)
|
|
77
|
-
usage_data[:total_tokens] = usage_data[:input_tokens].to_i + usage_data[:output_tokens].to_i
|
|
78
|
-
usage_data
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def clean_request_headers(headers)
|
|
83
|
-
cleaned = headers.dup
|
|
84
|
-
|
|
85
|
-
# Remove sensitive headers
|
|
86
|
-
cleaned.delete("Authorization")
|
|
87
|
-
cleaned.delete("authorization")
|
|
88
|
-
cleaned.delete("x-api-key")
|
|
89
|
-
cleaned.delete("X-API-Key")
|
|
90
|
-
|
|
91
|
-
cleaned
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def clean_response_headers(headers)
|
|
95
|
-
# Response headers typically don't contain sensitive data
|
|
96
|
-
# but we can filter if needed
|
|
97
|
-
headers.dup
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def sanitize_headers(headers)
|
|
101
|
-
sanitized = headers.transform_keys(&:to_s).dup
|
|
102
|
-
|
|
103
|
-
if sanitized["Authorization"]
|
|
104
|
-
sanitized["Authorization"] = sanitized["Authorization"].gsub(/Bearer .+/, "Bearer [REDACTED]")
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
%w[openai-api-key api-key x-api-key X-API-Key].each do |key|
|
|
108
|
-
sanitized[key] = "[REDACTED]" if sanitized[key]
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
sanitized
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def send_complete_request_log(request_id:, method:, url:, request_headers:, request_body:, response_headers:,
|
|
115
|
-
response_body:, status_code:, start_time:, end_time:, duration_ms:, is_streaming:)
|
|
116
|
-
request_data = {
|
|
117
|
-
raw_request: {
|
|
118
|
-
id: request_id,
|
|
119
|
-
timestamp: start_time.iso8601,
|
|
120
|
-
method: method.to_s.downcase,
|
|
121
|
-
url: url,
|
|
122
|
-
headers: request_headers,
|
|
123
|
-
request_body: request_body,
|
|
124
|
-
response_headers: response_headers,
|
|
125
|
-
response_body: response_body,
|
|
126
|
-
status_code: status_code,
|
|
127
|
-
duration_ms: duration_ms,
|
|
128
|
-
completed_at: end_time.iso8601,
|
|
129
|
-
is_streaming: is_streaming
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
api_service = Coolhand::Ruby::ApiService.new
|
|
134
|
-
api_service.send_llm_request_log(request_data)
|
|
135
|
-
|
|
136
|
-
Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
|
|
137
|
-
rescue StandardError => e
|
|
138
|
-
Coolhand.log "❌ Error sending complete request log: #{e.message}"
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def parse_json(string)
|
|
142
|
-
JSON.parse(string)
|
|
143
|
-
rescue JSON::ParserError, TypeError
|
|
144
|
-
string
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
end
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Coolhand
|
|
4
|
-
module Ruby
|
|
5
|
-
# Utility for generating collector identification string
|
|
6
|
-
module Collector
|
|
7
|
-
COLLECTION_METHODS = %w[manual auto-monitor].freeze
|
|
8
|
-
|
|
9
|
-
class << self
|
|
10
|
-
# Gets the collector identification string
|
|
11
|
-
# Format: "coolhand-ruby-X.Y.Z" or "coolhand-ruby-X.Y.Z-method"
|
|
12
|
-
# @param method [String, nil] Optional collection method suffix
|
|
13
|
-
# @return [String] Collector string identifying this SDK version and collection method
|
|
14
|
-
def get_collector_string(method = nil)
|
|
15
|
-
base = "coolhand-ruby-#{VERSION}"
|
|
16
|
-
method && COLLECTION_METHODS.include?(method) ? "#{base}-#{method}" : base
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Coolhand
|
|
4
|
-
# Handles all configuration settings for the gem.
|
|
5
|
-
class Configuration
|
|
6
|
-
attr_accessor :api_key, :environment, :silent
|
|
7
|
-
attr_reader :intercept_addresses
|
|
8
|
-
|
|
9
|
-
def initialize
|
|
10
|
-
# Set defaults
|
|
11
|
-
@environment = "production"
|
|
12
|
-
@api_key = nil
|
|
13
|
-
@silent = false
|
|
14
|
-
@intercept_addresses = ["api.openai.com", "api.anthropic.com", "api.elevenlabs.io", ":generateContent"]
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
# Custom setter that preserves defaults when nil/empty array is provided
|
|
18
|
-
def intercept_addresses=(value)
|
|
19
|
-
return if value.nil? || (value.is_a?(Array) && value.empty?)
|
|
20
|
-
|
|
21
|
-
@intercept_addresses = value.is_a?(Array) ? value : [value]
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def validate!
|
|
25
|
-
# Validate API Key after configuration
|
|
26
|
-
if api_key.nil?
|
|
27
|
-
Coolhand.log "❌ Coolhand Error: API Key is required. Please set it in the configuration."
|
|
28
|
-
raise Error, "API Key is required"
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Validate intercept_addresses after configuration
|
|
32
|
-
if intercept_addresses.nil? || intercept_addresses.empty?
|
|
33
|
-
Coolhand.log "❌ Coolhand Error: Intercept addresses cannot be empty. Please set it in the configuration."
|
|
34
|
-
raise Error, "Intercept addresses cannot be empty"
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "base_interceptor"
|
|
4
|
-
|
|
5
|
-
module Coolhand
|
|
6
|
-
module Ruby
|
|
7
|
-
class FaradayInterceptor < Faraday::Middleware
|
|
8
|
-
include BaseInterceptor
|
|
9
|
-
|
|
10
|
-
ORIGINAL_METHOD_ALIAS = :coolhand_original_initialize
|
|
11
|
-
|
|
12
|
-
def self.patch!
|
|
13
|
-
return if @patched
|
|
14
|
-
|
|
15
|
-
@patched = true
|
|
16
|
-
Coolhand.log "📡 Monitoring outbound requests ..."
|
|
17
|
-
|
|
18
|
-
# Use prepend instead of alias_method to avoid conflicts with other gems
|
|
19
|
-
Faraday::Connection.prepend(Module.new do
|
|
20
|
-
def initialize(url = nil, options = nil, &block)
|
|
21
|
-
super
|
|
22
|
-
|
|
23
|
-
# Only add interceptor if it's not already present
|
|
24
|
-
unless @builder.handlers.any? { |h| h.klass == Coolhand::Ruby::FaradayInterceptor }
|
|
25
|
-
use Coolhand::Ruby::FaradayInterceptor
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end)
|
|
29
|
-
|
|
30
|
-
Coolhand.log "🔧 Setting up monitoring for Faraday ..."
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def self.unpatch!
|
|
34
|
-
# NOTE: With prepend, there's no clean way to unpatch
|
|
35
|
-
# We'll mark it as unpatched so it can be re-patched
|
|
36
|
-
@patched = false
|
|
37
|
-
Coolhand.log "🔌 Faraday monitoring disabled ..."
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def self.patched?
|
|
41
|
-
@patched
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def call(env)
|
|
45
|
-
# Skip if Faraday interception is temporarily disabled for this thread
|
|
46
|
-
return super if Thread.current[:coolhand_disable_faraday]
|
|
47
|
-
|
|
48
|
-
return super unless llm_api_request?(env)
|
|
49
|
-
|
|
50
|
-
Coolhand.log "🎯 INTERCEPTING OpenAI call #{env.url}"
|
|
51
|
-
|
|
52
|
-
call_data = build_call_data(env)
|
|
53
|
-
buffer = override_on_data(env)
|
|
54
|
-
|
|
55
|
-
process_complete_callback(env, buffer, call_data)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
private
|
|
59
|
-
|
|
60
|
-
def llm_api_request?(env)
|
|
61
|
-
Coolhand.configuration.intercept_addresses.any? do |address|
|
|
62
|
-
env.url.to_s.include?(address)
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def build_call_data(env)
|
|
67
|
-
{
|
|
68
|
-
id: SecureRandom.uuid,
|
|
69
|
-
timestamp: DateTime.now,
|
|
70
|
-
method: env.method,
|
|
71
|
-
url: env.url.to_s,
|
|
72
|
-
request_headers: sanitize_headers(env.request_headers),
|
|
73
|
-
request_body: parse_json(env.request_body),
|
|
74
|
-
response_body: nil,
|
|
75
|
-
response_headers: nil,
|
|
76
|
-
status_code: nil
|
|
77
|
-
}
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def override_on_data(env)
|
|
81
|
-
buffer = +""
|
|
82
|
-
original_on_data = env.request.on_data
|
|
83
|
-
env.request.on_data = proc do |chunk, overall_received_bytes|
|
|
84
|
-
buffer << chunk
|
|
85
|
-
|
|
86
|
-
original_on_data&.call(chunk, overall_received_bytes)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
buffer
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def process_complete_callback(env, buffer, call_data)
|
|
93
|
-
@app.call(env).on_complete do |response_env|
|
|
94
|
-
if buffer.empty?
|
|
95
|
-
body = response_env.body
|
|
96
|
-
else
|
|
97
|
-
body = buffer
|
|
98
|
-
response_env.body = body
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
call_data[:response_body] = parse_json(body)
|
|
102
|
-
call_data[:response_headers] = sanitize_headers(response_env.response_headers)
|
|
103
|
-
call_data[:status_code] = response_env.status
|
|
104
|
-
|
|
105
|
-
end_time = Time.now
|
|
106
|
-
duration_ms = ((end_time - call_data[:timestamp].to_time) * 1000).round(2)
|
|
107
|
-
|
|
108
|
-
# Send complete request/response data in single API call
|
|
109
|
-
Thread.new do
|
|
110
|
-
send_complete_request_log(
|
|
111
|
-
request_id: call_data[:id],
|
|
112
|
-
method: call_data[:method],
|
|
113
|
-
url: call_data[:url],
|
|
114
|
-
request_headers: call_data[:request_headers],
|
|
115
|
-
request_body: call_data[:request_body],
|
|
116
|
-
response_headers: call_data[:response_headers],
|
|
117
|
-
response_body: call_data[:response_body],
|
|
118
|
-
status_code: call_data[:status_code],
|
|
119
|
-
start_time: call_data[:timestamp],
|
|
120
|
-
end_time: end_time,
|
|
121
|
-
duration_ms: duration_ms,
|
|
122
|
-
is_streaming: false
|
|
123
|
-
)
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
end
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "api_service"
|
|
4
|
-
|
|
5
|
-
module Coolhand
|
|
6
|
-
module Ruby
|
|
7
|
-
class FeedbackService < ApiService
|
|
8
|
-
def initialize
|
|
9
|
-
super("v2/llm_request_log_feedbacks")
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def create_feedback(feedback)
|
|
13
|
-
super(feedback, "manual")
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
end
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "api_service"
|
|
4
|
-
|
|
5
|
-
module Coolhand
|
|
6
|
-
module Ruby
|
|
7
|
-
class LoggerService < ApiService
|
|
8
|
-
def initialize
|
|
9
|
-
super("v2/llm_request_logs")
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def log_to_api(captured_data)
|
|
13
|
-
create_log(captured_data, "auto-monitor")
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
# Helper method for forwarding webhook data to Coolhand
|
|
17
|
-
def forward_webhook(webhook_body:, source:, event_type: nil, headers: {}, **options)
|
|
18
|
-
# Validate required parameters
|
|
19
|
-
unless Coolhand.required_field?(webhook_body)
|
|
20
|
-
error_msg = "webhook_body is required and cannot be nil or empty"
|
|
21
|
-
if Coolhand.configuration.silent
|
|
22
|
-
puts "COOLHAND WARNING: #{error_msg}"
|
|
23
|
-
return false
|
|
24
|
-
else
|
|
25
|
-
raise ArgumentError, error_msg
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
unless Coolhand.required_field?(source)
|
|
30
|
-
error_msg = "source is required and cannot be nil or empty"
|
|
31
|
-
if Coolhand.configuration.silent
|
|
32
|
-
puts "COOLHAND WARNING: #{error_msg}"
|
|
33
|
-
return false
|
|
34
|
-
else
|
|
35
|
-
raise ArgumentError, error_msg
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# Auto-generate required fields unless provided
|
|
40
|
-
webhook_data = {
|
|
41
|
-
id: options[:id] || SecureRandom.uuid,
|
|
42
|
-
timestamp: options[:timestamp] || Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ"),
|
|
43
|
-
method: options[:method] || "POST",
|
|
44
|
-
url: options[:url] || build_webhook_url(source, event_type),
|
|
45
|
-
headers: sanitize_headers(headers),
|
|
46
|
-
request_body: clean_webhook_body(webhook_body, source),
|
|
47
|
-
response_body: options[:response_body],
|
|
48
|
-
response_headers: options[:response_headers],
|
|
49
|
-
status_code: options[:status_code] || 200,
|
|
50
|
-
source: "#{source}_webhook"
|
|
51
|
-
}.merge(options.slice(:metadata, :conversation_id, :agent_id))
|
|
52
|
-
|
|
53
|
-
# Send to API asynchronously
|
|
54
|
-
log_to_api(webhook_data)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
private
|
|
58
|
-
|
|
59
|
-
def build_webhook_url(source, event_type)
|
|
60
|
-
base = "webhook://#{source}"
|
|
61
|
-
event_type ? "#{base}/#{event_type}" : base
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def sanitize_headers(headers)
|
|
65
|
-
return {} if headers.nil?
|
|
66
|
-
return {} if headers.respond_to?(:empty?) && headers.empty?
|
|
67
|
-
return {} unless headers.respond_to?(:each)
|
|
68
|
-
|
|
69
|
-
# Handle Rails request headers or plain hash
|
|
70
|
-
clean_headers = {}
|
|
71
|
-
headers.each do |key, value|
|
|
72
|
-
next unless key && value
|
|
73
|
-
|
|
74
|
-
# Convert Rails HTTP_ prefix headers
|
|
75
|
-
clean_key = key.to_s.gsub(/^HTTP_/, "").tr("_", "-").downcase
|
|
76
|
-
|
|
77
|
-
# Redact sensitive headers
|
|
78
|
-
clean_value = clean_key.match?(/key|token|secret|authorization|signature/i) ? "[REDACTED]" : value.to_s
|
|
79
|
-
clean_headers[clean_key] = clean_value
|
|
80
|
-
end
|
|
81
|
-
clean_headers
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def clean_webhook_body(body, source)
|
|
85
|
-
# Service-specific binary field filters
|
|
86
|
-
binary_fields = case source.to_s.downcase
|
|
87
|
-
when "elevenlabs"
|
|
88
|
-
%w[full_audio audio audio_data raw_audio audio_base64 voice_sample audio_url]
|
|
89
|
-
when "twilio"
|
|
90
|
-
%w[recording_url media_url]
|
|
91
|
-
else
|
|
92
|
-
%w[audio_data image_data file_content binary_content]
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
remove_binary_fields(body, binary_fields)
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def remove_binary_fields(obj, fields_to_remove)
|
|
99
|
-
case obj
|
|
100
|
-
when Hash
|
|
101
|
-
obj.each_with_object({}) do |(key, value), cleaned|
|
|
102
|
-
next if fields_to_remove.any? { |field| key.to_s.downcase.include?(field) }
|
|
103
|
-
|
|
104
|
-
cleaned[key] = remove_binary_fields(value, fields_to_remove)
|
|
105
|
-
end
|
|
106
|
-
when Array
|
|
107
|
-
obj.map { |item| remove_binary_fields(item, fields_to_remove) }
|
|
108
|
-
else
|
|
109
|
-
obj
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
end
|