coolhand 0.2.0 → 0.4.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.
@@ -0,0 +1,277 @@
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
+ return nil if missing_api_key?
69
+
70
+ uri = URI.parse(@api_endpoint)
71
+ http = Net::HTTP.new(uri.host, uri.port)
72
+ http.use_ssl = (uri.scheme == "https")
73
+
74
+ request = Net::HTTP::Post.new(uri.request_uri)
75
+ headers = create_request_options(payload)
76
+ headers.each do |key, value|
77
+ # Ensure header values are UTF-8 encoded
78
+ encoded_value = value.is_a?(String) ? value.dup.force_encoding("UTF-8") : value
79
+ request[key] = encoded_value
80
+ end
81
+
82
+ # Clean payload and ensure UTF-8 encoding before JSON generation
83
+ cleaned_payload = sanitize_payload_for_json(payload)
84
+ json_body = JSON.generate(cleaned_payload)
85
+
86
+ # Ensure the request body is properly encoded as UTF-8
87
+ request.body = json_body.force_encoding("UTF-8")
88
+
89
+ begin
90
+ response = http.request(request)
91
+
92
+ if response.is_a?(Net::HTTPSuccess)
93
+ result = JSON.parse(response.body, symbolize_names: true)
94
+ log success_message
95
+ result
96
+ else
97
+ body = response.body.force_encoding("UTF-8") if response.body
98
+ # Only show first part of HTML error pages
99
+ error_msg = if body&.include?("<!DOCTYPE html>")
100
+ "#{body[0..200]}... [HTML error page truncated]"
101
+ else
102
+ body
103
+ end
104
+ log "❌ Request failed: #{response.code} - #{error_msg}"
105
+ nil
106
+ end
107
+ rescue StandardError => e
108
+ log "❌ Request error: #{e.message}"
109
+ nil
110
+ end
111
+ end
112
+
113
+ def log(*args)
114
+ puts args.join(" ") unless silent
115
+ end
116
+
117
+ def log_separator
118
+ log("═" * 60) unless silent
119
+ end
120
+
121
+ def create_feedback(feedback, collection_method = nil)
122
+ return nil if !debug_mode? && missing_api_key?
123
+
124
+ normalized = normalize_feedback_sentiment(feedback)
125
+ feedback_with_collector = add_collector_to_data(normalized, collection_method)
126
+
127
+ payload = {
128
+ llm_request_log_feedback: feedback_with_collector
129
+ }
130
+
131
+ log_feedback_info(normalized)
132
+
133
+ if debug_mode?
134
+ log_separator
135
+ log "🛠️ Debug Mode - Request payload prepared but not sent to API:"
136
+ log JSON.pretty_generate(payload)
137
+ nil
138
+ else
139
+ result = send_request(
140
+ payload,
141
+ "✅ Successfully created feedback with ID: #{feedback[:llm_request_log_id] || 'N/A'}"
142
+ )
143
+
144
+ log_separator
145
+
146
+ result
147
+ end
148
+ end
149
+
150
+ def create_log(captured_data, collection_method = nil)
151
+ return nil if !debug_mode? && missing_api_key?
152
+
153
+ raw_request_with_collector = add_collector_to_data({ raw_request: captured_data }, collection_method)
154
+
155
+ payload = {
156
+ llm_request_log: raw_request_with_collector
157
+ }
158
+
159
+ log_request_info(captured_data)
160
+
161
+ if debug_mode?
162
+ log_separator
163
+ log "🛠️ Debug Mode - Request payload prepared but not sent to API:"
164
+ log JSON.pretty_generate(payload)
165
+ nil
166
+ else
167
+ result = send_request(
168
+ payload,
169
+ "✅ Successfully logged to API"
170
+ )
171
+
172
+ puts "✅ Successfully logged to API with ID: #{result[:id]}" if result && !silent
173
+
174
+ log_separator
175
+ result
176
+ end
177
+ end
178
+
179
+ # Filter list of known binary/problematic field names by service
180
+ BINARY_DATA_FILTERS = {
181
+ # ElevenLabs fields that contain binary audio data
182
+ elevenlabs: %w[
183
+ full_audio
184
+ audio
185
+ audio_data
186
+ raw_audio
187
+ audio_base64
188
+ voice_sample
189
+ audio_url
190
+ ],
191
+ # OpenAI fields that might contain binary data
192
+ openai: %w[
193
+ file_content
194
+ audio_data
195
+ image_data
196
+ binary_content
197
+ ]
198
+ }.freeze
199
+
200
+ private
201
+
202
+ def missing_api_key?
203
+ return false if Coolhand.required_field?(api_key)
204
+
205
+ Coolhand.log "⚠️ Coolhand: API key is missing — skipping log for this request."
206
+ true
207
+ end
208
+
209
+ # Get all filtered field names as a flat array
210
+ def filtered_field_names
211
+ @filtered_field_names ||= BINARY_DATA_FILTERS.values.flatten.map(&:downcase)
212
+ end
213
+
214
+ # Recursively sanitize payload to remove known problematic fields
215
+ def sanitize_payload_for_json(obj)
216
+ case obj
217
+ when Hash
218
+ obj.each_with_object({}) do |(key, value), sanitized|
219
+ key_str = key.to_s.downcase
220
+
221
+ # Skip if key matches any filtered field name
222
+ next if filtered_field_names.any? { |filter| key_str.include?(filter) }
223
+
224
+ sanitized[key] = sanitize_payload_for_json(value)
225
+ end
226
+ when Array
227
+ obj.map { |item| sanitize_payload_for_json(item) }
228
+ else
229
+ obj
230
+ end
231
+ rescue StandardError => e
232
+ log "⚠️ Warning: Error sanitizing payload: #{e.message}"
233
+ obj
234
+ end
235
+
236
+ def normalize_feedback_sentiment(feedback)
237
+ return feedback.except(:like) if feedback[:sentiment]
238
+ return feedback if feedback[:like].nil?
239
+
240
+ sentiment = feedback[:like] ? "like" : "dislike"
241
+ feedback.except(:like).merge(sentiment: sentiment)
242
+ end
243
+
244
+ def log_feedback_info(feedback)
245
+ return if silent
246
+
247
+ # Log the appropriate identifier based on what was provided
248
+ if feedback[:llm_request_log_id]
249
+ puts "\n📝 CREATING FEEDBACK for LLM Request Log ID: #{feedback[:llm_request_log_id]}"
250
+ elsif feedback[:llm_provider_unique_id]
251
+ puts "\n📝 CREATING FEEDBACK for Provider Unique ID: #{feedback[:llm_provider_unique_id]}"
252
+ else
253
+ puts "\n📝 CREATING FEEDBACK"
254
+ end
255
+
256
+ puts "💬 Sentiment: #{feedback[:sentiment]}" if feedback[:sentiment]
257
+
258
+ puts "🔗 Workload: #{feedback[:workload_hashid]}" if feedback[:workload_hashid]
259
+
260
+ if feedback[:explanation]
261
+ explanation = feedback[:explanation]
262
+ truncated = explanation.length > 100 ? "#{explanation[0..99]}..." : explanation
263
+ puts "💭 Explanation: #{truncated}"
264
+ end
265
+
266
+ puts "📤 Sending to: #{@api_endpoint}"
267
+ end
268
+
269
+ def log_request_info(captured_data)
270
+ return if silent
271
+
272
+ puts "\n🎉 LOGGING OpenAI API Call #{@api_endpoint}"
273
+ puts captured_data
274
+ puts "📤 Sending to: #{@api_endpoint}"
275
+ end
276
+ end
277
+ 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,79 @@
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, :enabled
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
+ @enabled = true
34
+ end
35
+
36
+ # Custom setter that preserves defaults when nil/empty array is provided
37
+ def intercept_addresses=(value)
38
+ return if value.nil? || (value.is_a?(Array) && value.empty?)
39
+
40
+ @intercept_addresses = value.is_a?(Array) ? value : [value]
41
+ end
42
+
43
+ def base_url=(value)
44
+ stripped = value&.sub(%r{/+\z}, "")
45
+ raise Error, BASE_URL_ERROR_MSG unless stripped.nil? || valid_base_url?(stripped)
46
+
47
+ @base_url = stripped
48
+ end
49
+
50
+ def validate!
51
+ # Validate intercept_addresses after configuration
52
+ if intercept_addresses.nil? || intercept_addresses.empty?
53
+ Coolhand.log "❌ Coolhand Error: Intercept addresses cannot be empty. Please set it in the configuration."
54
+ raise Error, "Intercept addresses cannot be empty"
55
+ end
56
+
57
+ unless valid_base_url?(base_url)
58
+ Coolhand.log "❌ Coolhand Error: #{BASE_URL_ERROR_MSG}"
59
+ raise Error, BASE_URL_ERROR_MSG
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def valid_base_url?(url)
66
+ return false if url.nil? || url.empty?
67
+
68
+ parsed = URI.parse(url)
69
+ host = parsed.hostname&.downcase
70
+
71
+ return false if host.nil? || host.empty?
72
+ return true if parsed.scheme == "https"
73
+
74
+ parsed.scheme == "http" && LOOPBACK_HOSTS.include?(host)
75
+ rescue URI::InvalidURIError
76
+ false
77
+ end
78
+ end
79
+ 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,19 @@
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"
16
+ - "aiplatform.googleapis.com"
17
+ - "gateway.ai.cloudflare.com"
18
+ - "bedrock-runtime"
19
+ - "openrouter.ai"
@@ -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