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.
@@ -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