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,264 @@
|
|
|
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
|
+
class ApiService
|
|
10
|
+
attr_reader :api_endpoint
|
|
11
|
+
|
|
12
|
+
def initialize(endpoint = "v2/llm_request_logs")
|
|
13
|
+
@api_endpoint = "#{base_url}/#{endpoint}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def send_llm_request_log(request_data)
|
|
17
|
+
payload = {
|
|
18
|
+
llm_request_log: request_data.merge(
|
|
19
|
+
collector: Collector.get_collector_string
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if debug_mode?
|
|
24
|
+
log_separator
|
|
25
|
+
log "🛠️ Debug Mode - Request payload prepared but not sent to API:"
|
|
26
|
+
log JSON.pretty_generate(sanitize_payload_for_json(payload))
|
|
27
|
+
nil
|
|
28
|
+
else
|
|
29
|
+
send_request(payload, "✅ Successfully sent request metadata")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def configuration
|
|
34
|
+
Coolhand.configuration
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def base_url
|
|
38
|
+
configuration.base_url
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def api_key
|
|
42
|
+
configuration.api_key
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def silent
|
|
46
|
+
configuration.silent
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def debug_mode?
|
|
50
|
+
configuration.debug_mode
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
protected
|
|
54
|
+
|
|
55
|
+
# Add collector field to the data being sent
|
|
56
|
+
def add_collector_to_data(data, collection_method = nil)
|
|
57
|
+
data.merge(collector: Collector.get_collector_string(collection_method))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def create_request_options(_payload)
|
|
61
|
+
{
|
|
62
|
+
"Content-Type" => "application/json",
|
|
63
|
+
"X-API-Key" => api_key
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def send_request(payload, success_message)
|
|
68
|
+
uri = URI.parse(@api_endpoint)
|
|
69
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
70
|
+
http.use_ssl = (uri.scheme == "https")
|
|
71
|
+
|
|
72
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
73
|
+
headers = create_request_options(payload)
|
|
74
|
+
headers.each do |key, value|
|
|
75
|
+
# Ensure header values are UTF-8 encoded
|
|
76
|
+
encoded_value = value.is_a?(String) ? value.dup.force_encoding("UTF-8") : value
|
|
77
|
+
request[key] = encoded_value
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Clean payload and ensure UTF-8 encoding before JSON generation
|
|
81
|
+
cleaned_payload = sanitize_payload_for_json(payload)
|
|
82
|
+
json_body = JSON.generate(cleaned_payload)
|
|
83
|
+
|
|
84
|
+
# Ensure the request body is properly encoded as UTF-8
|
|
85
|
+
request.body = json_body.force_encoding("UTF-8")
|
|
86
|
+
|
|
87
|
+
begin
|
|
88
|
+
response = http.request(request)
|
|
89
|
+
|
|
90
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
91
|
+
result = JSON.parse(response.body, symbolize_names: true)
|
|
92
|
+
log success_message
|
|
93
|
+
result
|
|
94
|
+
else
|
|
95
|
+
body = response.body.force_encoding("UTF-8") if response.body
|
|
96
|
+
# Only show first part of HTML error pages
|
|
97
|
+
error_msg = if body&.include?("<!DOCTYPE html>")
|
|
98
|
+
"#{body[0..200]}... [HTML error page truncated]"
|
|
99
|
+
else
|
|
100
|
+
body
|
|
101
|
+
end
|
|
102
|
+
log "❌ Request failed: #{response.code} - #{error_msg}"
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
log "❌ Request error: #{e.message}"
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def log(*args)
|
|
112
|
+
puts args.join(" ") unless silent
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def log_separator
|
|
116
|
+
log("═" * 60) unless silent
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def create_feedback(feedback, collection_method = nil)
|
|
120
|
+
normalized = normalize_feedback_sentiment(feedback)
|
|
121
|
+
feedback_with_collector = add_collector_to_data(normalized, collection_method)
|
|
122
|
+
|
|
123
|
+
payload = {
|
|
124
|
+
llm_request_log_feedback: feedback_with_collector
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
log_feedback_info(normalized)
|
|
128
|
+
|
|
129
|
+
if debug_mode?
|
|
130
|
+
log_separator
|
|
131
|
+
log "🛠️ Debug Mode - Request payload prepared but not sent to API:"
|
|
132
|
+
log JSON.pretty_generate(payload)
|
|
133
|
+
nil
|
|
134
|
+
else
|
|
135
|
+
result = send_request(
|
|
136
|
+
payload,
|
|
137
|
+
"✅ Successfully created feedback with ID: #{feedback[:llm_request_log_id] || 'N/A'}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
log_separator
|
|
141
|
+
|
|
142
|
+
result
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def create_log(captured_data, collection_method = nil)
|
|
147
|
+
raw_request_with_collector = add_collector_to_data({ raw_request: captured_data }, collection_method)
|
|
148
|
+
|
|
149
|
+
payload = {
|
|
150
|
+
llm_request_log: raw_request_with_collector
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
log_request_info(captured_data)
|
|
154
|
+
|
|
155
|
+
if debug_mode?
|
|
156
|
+
log_separator
|
|
157
|
+
log "🛠️ Debug Mode - Request payload prepared but not sent to API:"
|
|
158
|
+
log JSON.pretty_generate(payload)
|
|
159
|
+
nil
|
|
160
|
+
else
|
|
161
|
+
result = send_request(
|
|
162
|
+
payload,
|
|
163
|
+
"✅ Successfully logged to API"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
puts "✅ Successfully logged to API with ID: #{result[:id]}" if result && !silent
|
|
167
|
+
|
|
168
|
+
log_separator
|
|
169
|
+
result
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Filter list of known binary/problematic field names by service
|
|
174
|
+
BINARY_DATA_FILTERS = {
|
|
175
|
+
# ElevenLabs fields that contain binary audio data
|
|
176
|
+
elevenlabs: %w[
|
|
177
|
+
full_audio
|
|
178
|
+
audio
|
|
179
|
+
audio_data
|
|
180
|
+
raw_audio
|
|
181
|
+
audio_base64
|
|
182
|
+
voice_sample
|
|
183
|
+
audio_url
|
|
184
|
+
],
|
|
185
|
+
# OpenAI fields that might contain binary data
|
|
186
|
+
openai: %w[
|
|
187
|
+
file_content
|
|
188
|
+
audio_data
|
|
189
|
+
image_data
|
|
190
|
+
binary_content
|
|
191
|
+
]
|
|
192
|
+
}.freeze
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
# Get all filtered field names as a flat array
|
|
197
|
+
def filtered_field_names
|
|
198
|
+
@filtered_field_names ||= BINARY_DATA_FILTERS.values.flatten.map(&:downcase)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Recursively sanitize payload to remove known problematic fields
|
|
202
|
+
def sanitize_payload_for_json(obj)
|
|
203
|
+
case obj
|
|
204
|
+
when Hash
|
|
205
|
+
obj.each_with_object({}) do |(key, value), sanitized|
|
|
206
|
+
key_str = key.to_s.downcase
|
|
207
|
+
|
|
208
|
+
# Skip if key matches any filtered field name
|
|
209
|
+
next if filtered_field_names.any? { |filter| key_str.include?(filter) }
|
|
210
|
+
|
|
211
|
+
sanitized[key] = sanitize_payload_for_json(value)
|
|
212
|
+
end
|
|
213
|
+
when Array
|
|
214
|
+
obj.map { |item| sanitize_payload_for_json(item) }
|
|
215
|
+
else
|
|
216
|
+
obj
|
|
217
|
+
end
|
|
218
|
+
rescue StandardError => e
|
|
219
|
+
log "⚠️ Warning: Error sanitizing payload: #{e.message}"
|
|
220
|
+
obj
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def normalize_feedback_sentiment(feedback)
|
|
224
|
+
return feedback.except(:like) if feedback[:sentiment]
|
|
225
|
+
return feedback if feedback[:like].nil?
|
|
226
|
+
|
|
227
|
+
sentiment = feedback[:like] ? "like" : "dislike"
|
|
228
|
+
feedback.except(:like).merge(sentiment: sentiment)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def log_feedback_info(feedback)
|
|
232
|
+
return if silent
|
|
233
|
+
|
|
234
|
+
# Log the appropriate identifier based on what was provided
|
|
235
|
+
if feedback[:llm_request_log_id]
|
|
236
|
+
puts "\n📝 CREATING FEEDBACK for LLM Request Log ID: #{feedback[:llm_request_log_id]}"
|
|
237
|
+
elsif feedback[:llm_provider_unique_id]
|
|
238
|
+
puts "\n📝 CREATING FEEDBACK for Provider Unique ID: #{feedback[:llm_provider_unique_id]}"
|
|
239
|
+
else
|
|
240
|
+
puts "\n📝 CREATING FEEDBACK"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
puts "💬 Sentiment: #{feedback[:sentiment]}" if feedback[:sentiment]
|
|
244
|
+
|
|
245
|
+
puts "🔗 Workload: #{feedback[:workload_hashid]}" if feedback[:workload_hashid]
|
|
246
|
+
|
|
247
|
+
if feedback[:explanation]
|
|
248
|
+
explanation = feedback[:explanation]
|
|
249
|
+
truncated = explanation.length > 100 ? "#{explanation[0..99]}..." : explanation
|
|
250
|
+
puts "💭 Explanation: #{truncated}"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
puts "📤 Sending to: #{@api_endpoint}"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def log_request_info(captured_data)
|
|
257
|
+
return if silent
|
|
258
|
+
|
|
259
|
+
puts "\n🎉 LOGGING OpenAI API Call #{@api_endpoint}"
|
|
260
|
+
puts captured_data
|
|
261
|
+
puts "📤 Sending to: #{@api_endpoint}"
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
# Base module with common functionality for all interceptors
|
|
5
|
+
module BaseInterceptor
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def extract_response_data(response)
|
|
9
|
+
case response
|
|
10
|
+
when Hash
|
|
11
|
+
response
|
|
12
|
+
when Struct
|
|
13
|
+
response.to_h
|
|
14
|
+
else
|
|
15
|
+
# Handle streaming responses - these are often enumerator objects
|
|
16
|
+
# that can't be serialized directly
|
|
17
|
+
if response.class.name.include?("Stream") || response.respond_to?(:each)
|
|
18
|
+
{
|
|
19
|
+
response_type: "streaming",
|
|
20
|
+
class: response.class.name,
|
|
21
|
+
note: "Streaming response - content captured during enumeration"
|
|
22
|
+
}
|
|
23
|
+
elsif response.respond_to?(:to_h)
|
|
24
|
+
begin
|
|
25
|
+
response.to_h
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
{
|
|
28
|
+
serialization_error: e.message,
|
|
29
|
+
class: response.class.name,
|
|
30
|
+
raw_response: response.to_s
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
else
|
|
34
|
+
# Extract content and token usage information
|
|
35
|
+
response_data = {}
|
|
36
|
+
|
|
37
|
+
# Get content
|
|
38
|
+
response_data[:content] = response.content if response.respond_to?(:content)
|
|
39
|
+
|
|
40
|
+
# Extract token usage information
|
|
41
|
+
response_data[:usage] = extract_usage_metadata(response.usage) if response.respond_to?(:usage)
|
|
42
|
+
|
|
43
|
+
# Extract model information
|
|
44
|
+
response_data[:model] = response.model if response.respond_to?(:model)
|
|
45
|
+
|
|
46
|
+
# Extract role information
|
|
47
|
+
response_data[:role] = response.role if response.respond_to?(:role)
|
|
48
|
+
|
|
49
|
+
# Extract ID if available
|
|
50
|
+
response_data[:id] = response.id if response.respond_to?(:id)
|
|
51
|
+
|
|
52
|
+
# Extract stop reason if available
|
|
53
|
+
response_data[:stop_reason] = response.stop_reason if response.respond_to?(:stop_reason)
|
|
54
|
+
|
|
55
|
+
# Add class info for debugging
|
|
56
|
+
response_data[:class] = response.class.name
|
|
57
|
+
|
|
58
|
+
response_data.empty? ? { raw_response: response.to_s, class: response.class.name } : response_data
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def extract_usage_metadata(usage)
|
|
64
|
+
if usage.respond_to?(:to_h)
|
|
65
|
+
usage.to_h
|
|
66
|
+
elsif usage.is_a?(Hash)
|
|
67
|
+
usage
|
|
68
|
+
else
|
|
69
|
+
# Extract individual usage fields
|
|
70
|
+
usage_data = {}
|
|
71
|
+
usage_data[:input_tokens] = usage.input_tokens if usage.respond_to?(:input_tokens)
|
|
72
|
+
usage_data[:output_tokens] = usage.output_tokens if usage.respond_to?(:output_tokens)
|
|
73
|
+
usage_data[:total_tokens] = usage_data[:input_tokens].to_i + usage_data[:output_tokens].to_i
|
|
74
|
+
usage_data
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def clean_request_headers(headers)
|
|
79
|
+
cleaned = headers.dup
|
|
80
|
+
|
|
81
|
+
# Remove sensitive headers
|
|
82
|
+
cleaned.delete("Authorization")
|
|
83
|
+
cleaned.delete("authorization")
|
|
84
|
+
cleaned.delete("x-api-key")
|
|
85
|
+
cleaned.delete("X-API-Key")
|
|
86
|
+
|
|
87
|
+
cleaned
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def clean_response_headers(headers)
|
|
91
|
+
# Response headers typically don't contain sensitive data
|
|
92
|
+
# but we can filter if needed
|
|
93
|
+
headers.dup
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def sanitize_headers(headers)
|
|
97
|
+
return {} if headers.nil?
|
|
98
|
+
|
|
99
|
+
# Normalize various header-like objects into a Hash{String => String}
|
|
100
|
+
raw = if headers.is_a?(Hash)
|
|
101
|
+
headers.transform_keys(&:to_s).transform_values { |v| normalize_header_value(v) }
|
|
102
|
+
elsif headers.respond_to?(:to_hash)
|
|
103
|
+
begin
|
|
104
|
+
headers.to_hash.transform_keys(&:to_s).transform_values { |v| normalize_header_value(v) }
|
|
105
|
+
rescue StandardError
|
|
106
|
+
# fall through to other enumeration strategies
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
elsif headers.respond_to?(:each_header)
|
|
110
|
+
h = {}
|
|
111
|
+
headers.each_header { |k, v| h[k.to_s] = normalize_header_value(v) }
|
|
112
|
+
h
|
|
113
|
+
elsif headers.respond_to?(:each)
|
|
114
|
+
h = {}
|
|
115
|
+
headers.each { |k, v| h[k.to_s] = normalize_header_value(v) }
|
|
116
|
+
h
|
|
117
|
+
else
|
|
118
|
+
{ "raw" => headers.to_s }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
raw ||= {} # in case to_hash raised and nothing was built
|
|
122
|
+
|
|
123
|
+
sanitized = raw.dup
|
|
124
|
+
|
|
125
|
+
sanitized_keys = %w[openai-api-key api-key x-api-key x-goog-api-key]
|
|
126
|
+
sanitized.each do |k, v|
|
|
127
|
+
next if v.nil?
|
|
128
|
+
|
|
129
|
+
key_down = k.to_s.downcase
|
|
130
|
+
|
|
131
|
+
if key_down == "authorization"
|
|
132
|
+
sanitized[k] = if v.to_s.match?(/\ABearer\s+/i)
|
|
133
|
+
v.to_s.gsub(/\ABearer\s+.+/i, "Bearer [REDACTED]")
|
|
134
|
+
else
|
|
135
|
+
"[REDACTED]"
|
|
136
|
+
end
|
|
137
|
+
elsif sanitized_keys.include?(key_down)
|
|
138
|
+
sanitized[k] = "[REDACTED]"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
sanitized
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Helper: convert arrays -> joined string, otherwise to_s
|
|
146
|
+
def normalize_header_value(value)
|
|
147
|
+
if value.is_a?(Array)
|
|
148
|
+
value.join(", ")
|
|
149
|
+
else
|
|
150
|
+
value.to_s
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def sanitize_url(url)
|
|
155
|
+
uri = URI.parse(url)
|
|
156
|
+
return url unless uri.query
|
|
157
|
+
|
|
158
|
+
sensitive = %w[key api_key apikey token access_token secret]
|
|
159
|
+
params = URI.decode_www_form(uri.query)
|
|
160
|
+
redacted = false
|
|
161
|
+
params.map! do |n, v|
|
|
162
|
+
if sensitive.include?(n.downcase)
|
|
163
|
+
redacted = true
|
|
164
|
+
[n, "[REDACTED]"]
|
|
165
|
+
else
|
|
166
|
+
[n, v]
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
if redacted
|
|
171
|
+
uri.query = URI.encode_www_form(params)
|
|
172
|
+
uri.to_s
|
|
173
|
+
else
|
|
174
|
+
url
|
|
175
|
+
end
|
|
176
|
+
rescue URI::InvalidURIError
|
|
177
|
+
url
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def send_complete_request_log(request_id:, method:, url:, request_headers:, request_body:, response_headers:,
|
|
181
|
+
response_body:, status_code:, start_time:, end_time:, duration_ms:, is_streaming:)
|
|
182
|
+
request_data = {
|
|
183
|
+
raw_request: {
|
|
184
|
+
id: request_id,
|
|
185
|
+
timestamp: start_time.iso8601,
|
|
186
|
+
method: method.to_s.downcase,
|
|
187
|
+
url: sanitize_url(url),
|
|
188
|
+
headers: request_headers,
|
|
189
|
+
request_body: request_body,
|
|
190
|
+
response_headers: response_headers,
|
|
191
|
+
response_body: response_body,
|
|
192
|
+
status_code: status_code,
|
|
193
|
+
duration_ms: duration_ms,
|
|
194
|
+
completed_at: end_time.iso8601,
|
|
195
|
+
is_streaming: is_streaming
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
api_service = Coolhand::ApiService.new
|
|
200
|
+
api_service.send_llm_request_log(request_data)
|
|
201
|
+
|
|
202
|
+
Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
|
|
203
|
+
rescue StandardError => e
|
|
204
|
+
Coolhand.log "❌ Error sending complete request log: #{e.message}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def parse_json(string)
|
|
208
|
+
JSON.parse(string)
|
|
209
|
+
rescue JSON::ParserError, TypeError
|
|
210
|
+
string
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
# Utility for generating collector identification string
|
|
5
|
+
module Collector
|
|
6
|
+
COLLECTION_METHODS = %w[manual auto-monitor].freeze
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
# Gets the collector identification string
|
|
10
|
+
# Format: "coolhand-ruby-X.Y.Z" or "coolhand-ruby-X.Y.Z-method"
|
|
11
|
+
# @param method [String, nil] Optional collection method suffix
|
|
12
|
+
# @return [String] Collector string identifying this SDK version and collection method
|
|
13
|
+
def get_collector_string(method = nil)
|
|
14
|
+
base = "coolhand-ruby-#{VERSION}"
|
|
15
|
+
method && COLLECTION_METHODS.include?(method) ? "#{base}-#{method}" : base
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Coolhand
|
|
7
|
+
# Handles all configuration settings for the gem.
|
|
8
|
+
class Configuration
|
|
9
|
+
DEFAULT_EXCLUDE_API_PATTERNS = YAML.load_file(
|
|
10
|
+
File.join(__dir__, "default_exclude_api_patterns.yml")
|
|
11
|
+
).freeze
|
|
12
|
+
|
|
13
|
+
DEFAULT_INTERCEPT_ADDRESSES = YAML.load_file(
|
|
14
|
+
File.join(__dir__, "default_intercept_addresses.yml")
|
|
15
|
+
).freeze
|
|
16
|
+
|
|
17
|
+
BASE_URL_ERROR_MSG = "base_url must use https:// (or http://localhost / http://127.0.0.1 for local dev)"
|
|
18
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
|
|
19
|
+
|
|
20
|
+
attr_accessor :api_key, :environment, :silent, :debug_mode, :capture, :exclude_api_patterns
|
|
21
|
+
attr_reader :intercept_addresses, :base_url
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
# Set defaults
|
|
25
|
+
@environment = "production"
|
|
26
|
+
@api_key = nil
|
|
27
|
+
@silent = false
|
|
28
|
+
@intercept_addresses = DEFAULT_INTERCEPT_ADDRESSES.dup
|
|
29
|
+
self.base_url = "https://coolhandlabs.com/api"
|
|
30
|
+
@debug_mode = false
|
|
31
|
+
@capture = true
|
|
32
|
+
@exclude_api_patterns = DEFAULT_EXCLUDE_API_PATTERNS.dup
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Custom setter that preserves defaults when nil/empty array is provided
|
|
36
|
+
def intercept_addresses=(value)
|
|
37
|
+
return if value.nil? || (value.is_a?(Array) && value.empty?)
|
|
38
|
+
|
|
39
|
+
@intercept_addresses = value.is_a?(Array) ? value : [value]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def base_url=(value)
|
|
43
|
+
stripped = value&.sub(%r{/+\z}, "")
|
|
44
|
+
raise Error, BASE_URL_ERROR_MSG unless stripped.nil? || valid_base_url?(stripped)
|
|
45
|
+
|
|
46
|
+
@base_url = stripped
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def validate!
|
|
50
|
+
# Validate API Key after configuration
|
|
51
|
+
if api_key.nil?
|
|
52
|
+
Coolhand.log "❌ Coolhand Error: API Key is required. Please set it in the configuration."
|
|
53
|
+
raise Error, "API Key is required"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Validate intercept_addresses after configuration
|
|
57
|
+
if intercept_addresses.nil? || intercept_addresses.empty?
|
|
58
|
+
Coolhand.log "❌ Coolhand Error: Intercept addresses cannot be empty. Please set it in the configuration."
|
|
59
|
+
raise Error, "Intercept addresses cannot be empty"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
unless valid_base_url?(base_url)
|
|
63
|
+
Coolhand.log "❌ Coolhand Error: #{BASE_URL_ERROR_MSG}"
|
|
64
|
+
raise Error, BASE_URL_ERROR_MSG
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def valid_base_url?(url)
|
|
71
|
+
return false if url.nil? || url.empty?
|
|
72
|
+
|
|
73
|
+
parsed = URI.parse(url)
|
|
74
|
+
host = parsed.hostname&.downcase
|
|
75
|
+
|
|
76
|
+
return false if host.nil? || host.empty?
|
|
77
|
+
return true if parsed.scheme == "https"
|
|
78
|
+
|
|
79
|
+
parsed.scheme == "http" && LOOPBACK_HOSTS.include?(host)
|
|
80
|
+
rescue URI::InvalidURIError
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Coolhand default exclude API patterns
|
|
2
|
+
# These substrings are matched against request URLs after the intercept_addresses
|
|
3
|
+
# allow-list passes. Matching URLs are skipped and not forwarded as llm_request_logs.
|
|
4
|
+
#
|
|
5
|
+
# Users can extend defaults: c.exclude_api_patterns << "/myOperationalPath/"
|
|
6
|
+
# Users can override entirely: c.exclude_api_patterns = ["/only_this/"]
|
|
7
|
+
# Users can disable: c.exclude_api_patterns = []
|
|
8
|
+
|
|
9
|
+
- "/batchPredictionJobs/"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Coolhand default intercept addresses
|
|
2
|
+
# These substrings are matched against request URLs to decide whether a request
|
|
3
|
+
# should be captured and forwarded as an llm_request_log.
|
|
4
|
+
#
|
|
5
|
+
# Users can extend defaults: c.intercept_addresses << "my.custom.api.com"
|
|
6
|
+
# Users can override entirely: c.intercept_addresses = ["only.this.com"]
|
|
7
|
+
|
|
8
|
+
- "api.openai.com"
|
|
9
|
+
- "api.anthropic.com"
|
|
10
|
+
- "api.elevenlabs.io"
|
|
11
|
+
- "generativelanguage.googleapis.com"
|
|
12
|
+
- "models.github.ai"
|
|
13
|
+
- "models.inference.ai.azure.com"
|
|
14
|
+
- ":generateContent"
|
|
15
|
+
- ":streamGenerateContent"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "api_service"
|
|
4
|
+
|
|
5
|
+
module Coolhand
|
|
6
|
+
class FeedbackService < ApiService
|
|
7
|
+
def initialize
|
|
8
|
+
super("v2/llm_request_log_feedbacks")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def create_feedback(feedback)
|
|
12
|
+
super(feedback, "manual")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|