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