coolhand 0.1.5 → 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 +14 -1
- data/CHANGELOG.md +99 -1
- data/CLAUDE.md +34 -0
- data/README.md +165 -46
- data/coolhand-ruby.gemspec +10 -6
- data/docs/anthropic.md +518 -0
- 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 +41 -19
- data/lib/coolhand/ruby/api_service.rb +0 -211
- data/lib/coolhand/ruby/collector.rb +0 -21
- data/lib/coolhand/ruby/configuration.rb +0 -38
- data/lib/coolhand/ruby/feedback_service.rb +0 -17
- data/lib/coolhand/ruby/interceptor.rb +0 -119
- data/lib/coolhand/ruby/logger_service.rb +0 -114
- data/lib/coolhand/ruby.rb +0 -90
data/lib/coolhand.rb
CHANGED
|
@@ -1,4 +1,111 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
require "uri"
|
|
4
|
+
require "faraday"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "json"
|
|
7
|
+
require "base64"
|
|
8
|
+
|
|
9
|
+
require_relative "coolhand/version"
|
|
10
|
+
require_relative "coolhand/configuration"
|
|
11
|
+
require_relative "coolhand/collector"
|
|
12
|
+
require_relative "coolhand/base_interceptor"
|
|
13
|
+
require_relative "coolhand/net_http_interceptor"
|
|
14
|
+
require_relative "coolhand/api_service"
|
|
15
|
+
require_relative "coolhand/logger_service"
|
|
16
|
+
require_relative "coolhand/feedback_service"
|
|
17
|
+
require_relative "coolhand/webhook_interceptor"
|
|
18
|
+
|
|
19
|
+
# The main module for the Coolhand gem.
|
|
20
|
+
# It provides the configuration interface and initializes the patching.
|
|
21
|
+
module Coolhand
|
|
22
|
+
class Error < StandardError; end
|
|
23
|
+
|
|
24
|
+
# Class-level instance variables to hold the configuration
|
|
25
|
+
@configuration = Configuration.new
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
attr_reader :configuration
|
|
29
|
+
|
|
30
|
+
# Reset configuration to defaults (mainly for testing)
|
|
31
|
+
def reset_configuration!
|
|
32
|
+
@configuration = Configuration.new
|
|
33
|
+
Thread.current[:coolhand_capture_override] = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Provides a block to configure the gem.
|
|
37
|
+
#
|
|
38
|
+
# Example:
|
|
39
|
+
# Coolhand.configure do |config|
|
|
40
|
+
# config.environment = 'development'
|
|
41
|
+
# config.silent = false
|
|
42
|
+
# config.api_key = "xxx-yyy-zzz"
|
|
43
|
+
# config.intercept_addresses = ["openai.com", "api.anthropic.com"]
|
|
44
|
+
# end
|
|
45
|
+
def configure
|
|
46
|
+
yield(configuration)
|
|
47
|
+
|
|
48
|
+
configuration.validate!
|
|
49
|
+
|
|
50
|
+
NetHttpInterceptor.patch!
|
|
51
|
+
|
|
52
|
+
log "✅ Coolhand ready - will log inference calls on monitored URIs"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def capture
|
|
56
|
+
unless block_given?
|
|
57
|
+
log "❌ Coolhand Error: Method .capture requires block."
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
patched = NetHttpInterceptor.patched?
|
|
62
|
+
|
|
63
|
+
NetHttpInterceptor.patch!
|
|
64
|
+
|
|
65
|
+
yield
|
|
66
|
+
ensure
|
|
67
|
+
NetHttpInterceptor.unpatch! unless patched
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def without_capture
|
|
71
|
+
previous = Thread.current[:coolhand_capture_override]
|
|
72
|
+
Thread.current[:coolhand_capture_override] = false
|
|
73
|
+
yield
|
|
74
|
+
ensure
|
|
75
|
+
Thread.current[:coolhand_capture_override] = previous
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def with_capture
|
|
79
|
+
previous = Thread.current[:coolhand_capture_override]
|
|
80
|
+
Thread.current[:coolhand_capture_override] = true
|
|
81
|
+
yield
|
|
82
|
+
ensure
|
|
83
|
+
Thread.current[:coolhand_capture_override] = previous
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A simple logger that respects the 'silent' configuration option.
|
|
87
|
+
def log(message)
|
|
88
|
+
return if configuration.silent
|
|
89
|
+
|
|
90
|
+
puts "COOLHAND: #{message}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Creates a new FeedbackService instance
|
|
94
|
+
def feedback_service
|
|
95
|
+
FeedbackService.new
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Creates a new LoggerService instance
|
|
99
|
+
def logger_service
|
|
100
|
+
LoggerService.new
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def required_field?(value)
|
|
104
|
+
return false if value.nil?
|
|
105
|
+
return false if value.respond_to?(:empty?) && value.empty?
|
|
106
|
+
return false if value.to_s.strip.empty?
|
|
107
|
+
|
|
108
|
+
true
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
metadata
CHANGED
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: coolhand
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michael Carroll
|
|
8
8
|
- Yaroslav Malyk
|
|
9
|
-
autorequire:
|
|
10
9
|
bindir: exe
|
|
11
10
|
cert_chain: []
|
|
12
|
-
date:
|
|
13
|
-
dependencies:
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
date: 2026-05-16 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: base64
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0.2'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0.2'
|
|
27
|
+
description: Automatically intercept and log LLM requests from Ruby applications.
|
|
28
|
+
Supports OpenAI, official Anthropic gem, ruby-anthropic gem, and other Faraday-based
|
|
29
|
+
libraries. Features dual interceptor architecture, streaming support, thread-safe
|
|
30
|
+
operation, and automatic duplicate request prevention.
|
|
16
31
|
email:
|
|
17
32
|
- mc@coolhandlabs.com
|
|
18
33
|
executables: []
|
|
@@ -24,20 +39,28 @@ files:
|
|
|
24
39
|
- ".rubocop.yml"
|
|
25
40
|
- ".simplecov"
|
|
26
41
|
- CHANGELOG.md
|
|
42
|
+
- CLAUDE.md
|
|
27
43
|
- LICENSE
|
|
28
44
|
- README.md
|
|
29
45
|
- Rakefile
|
|
30
46
|
- coolhand-ruby.gemspec
|
|
47
|
+
- docs/anthropic.md
|
|
31
48
|
- docs/elevenlabs.md
|
|
32
49
|
- lib/coolhand.rb
|
|
33
|
-
- lib/coolhand/
|
|
34
|
-
- lib/coolhand/
|
|
35
|
-
- lib/coolhand/
|
|
36
|
-
- lib/coolhand/
|
|
37
|
-
- lib/coolhand/
|
|
38
|
-
- lib/coolhand/
|
|
39
|
-
- lib/coolhand/
|
|
40
|
-
- lib/coolhand/
|
|
50
|
+
- lib/coolhand/api_service.rb
|
|
51
|
+
- lib/coolhand/base_interceptor.rb
|
|
52
|
+
- lib/coolhand/collector.rb
|
|
53
|
+
- lib/coolhand/configuration.rb
|
|
54
|
+
- lib/coolhand/default_exclude_api_patterns.yml
|
|
55
|
+
- lib/coolhand/default_intercept_addresses.yml
|
|
56
|
+
- lib/coolhand/feedback_service.rb
|
|
57
|
+
- lib/coolhand/logger_service.rb
|
|
58
|
+
- lib/coolhand/net_http_interceptor.rb
|
|
59
|
+
- lib/coolhand/open_ai/batch_result_processor.rb
|
|
60
|
+
- lib/coolhand/open_ai/webhook_validator.rb
|
|
61
|
+
- lib/coolhand/version.rb
|
|
62
|
+
- lib/coolhand/vertex/batch_result_processor.rb
|
|
63
|
+
- lib/coolhand/webhook_interceptor.rb
|
|
41
64
|
- sig/coolhand/ruby.rbs
|
|
42
65
|
homepage: https://coolhandlabs.com/
|
|
43
66
|
licenses:
|
|
@@ -46,9 +69,8 @@ metadata:
|
|
|
46
69
|
allowed_push_host: https://rubygems.org
|
|
47
70
|
homepage_uri: https://coolhandlabs.com/
|
|
48
71
|
source_code_uri: https://github.com/Coolhand-Labs/coolhand-ruby
|
|
49
|
-
changelog_uri: https://github.com/Coolhand-Labs/coolhand-ruby
|
|
72
|
+
changelog_uri: https://github.com/Coolhand-Labs/coolhand-ruby/blob/main/CHANGELOG.md
|
|
50
73
|
rubygems_mfa_required: 'true'
|
|
51
|
-
post_install_message:
|
|
52
74
|
rdoc_options: []
|
|
53
75
|
require_paths:
|
|
54
76
|
- lib
|
|
@@ -63,8 +85,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
63
85
|
- !ruby/object:Gem::Version
|
|
64
86
|
version: '0'
|
|
65
87
|
requirements: []
|
|
66
|
-
rubygems_version: 3.
|
|
67
|
-
signing_key:
|
|
88
|
+
rubygems_version: 3.6.2
|
|
68
89
|
specification_version: 4
|
|
69
|
-
summary:
|
|
90
|
+
summary: Monitor and log LLM API calls from OpenAI, Anthropic, and other providers
|
|
91
|
+
to Coolhand analytics.
|
|
70
92
|
test_files: []
|
|
@@ -1,211 +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)
|
|
16
|
-
@api_endpoint = "#{BASE_URI}/#{endpoint_path}"
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def configuration
|
|
20
|
-
Coolhand.configuration
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def api_key
|
|
24
|
-
configuration.api_key
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def silent
|
|
28
|
-
configuration.silent
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
protected
|
|
32
|
-
|
|
33
|
-
# Add collector field to the data being sent
|
|
34
|
-
def add_collector_to_data(data, collection_method = nil)
|
|
35
|
-
data.merge(collector: Collector.get_collector_string(collection_method))
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def create_request_options(_payload)
|
|
39
|
-
{
|
|
40
|
-
"Content-Type" => "application/json",
|
|
41
|
-
"X-API-Key" => api_key
|
|
42
|
-
}
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def send_request(payload, success_message)
|
|
46
|
-
uri = URI.parse(@api_endpoint)
|
|
47
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
48
|
-
http.use_ssl = (uri.scheme == "https")
|
|
49
|
-
|
|
50
|
-
request = Net::HTTP::Post.new(uri.request_uri)
|
|
51
|
-
headers = create_request_options(payload)
|
|
52
|
-
headers.each do |key, value|
|
|
53
|
-
# Ensure header values are UTF-8 encoded
|
|
54
|
-
encoded_value = value.is_a?(String) ? value.dup.force_encoding("UTF-8") : value
|
|
55
|
-
request[key] = encoded_value
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# Clean payload and ensure UTF-8 encoding before JSON generation
|
|
59
|
-
cleaned_payload = sanitize_payload_for_json(payload)
|
|
60
|
-
json_body = JSON.generate(cleaned_payload)
|
|
61
|
-
|
|
62
|
-
# Ensure the request body is properly encoded as UTF-8
|
|
63
|
-
request.body = json_body.force_encoding("UTF-8")
|
|
64
|
-
|
|
65
|
-
begin
|
|
66
|
-
response = http.request(request)
|
|
67
|
-
|
|
68
|
-
if response.is_a?(Net::HTTPSuccess)
|
|
69
|
-
result = JSON.parse(response.body, symbolize_names: true)
|
|
70
|
-
log success_message
|
|
71
|
-
result
|
|
72
|
-
else
|
|
73
|
-
body = response.body.force_encoding("UTF-8") if response.body
|
|
74
|
-
puts "❌ Request failed: #{response.code} - #{body}"
|
|
75
|
-
nil
|
|
76
|
-
end
|
|
77
|
-
rescue StandardError => e
|
|
78
|
-
log "❌ Request error: #{e.message}"
|
|
79
|
-
nil
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def log(*args)
|
|
84
|
-
puts args.join(" ") unless silent
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def log_separator
|
|
88
|
-
log("═" * 60) unless silent
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def create_feedback(feedback, collection_method = nil)
|
|
92
|
-
feedback_with_collector = add_collector_to_data(feedback, collection_method)
|
|
93
|
-
|
|
94
|
-
payload = {
|
|
95
|
-
llm_request_log_feedback: feedback_with_collector
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
log_feedback_info(feedback)
|
|
99
|
-
|
|
100
|
-
result = send_request(
|
|
101
|
-
payload,
|
|
102
|
-
"✅ Successfully created feedback with ID: #{feedback[:llm_request_log_id] || 'N/A'}"
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
log_separator
|
|
106
|
-
result
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def create_log(captured_data, collection_method = nil)
|
|
110
|
-
raw_request_with_collector = add_collector_to_data({ raw_request: captured_data }, collection_method)
|
|
111
|
-
|
|
112
|
-
payload = {
|
|
113
|
-
llm_request_log: raw_request_with_collector
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
log_request_info(captured_data)
|
|
117
|
-
|
|
118
|
-
result = send_request(
|
|
119
|
-
payload,
|
|
120
|
-
"✅ Successfully logged to API"
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
puts "✅ Successfully logged to API with ID: #{result[:id]}" if result && !silent
|
|
124
|
-
|
|
125
|
-
log_separator
|
|
126
|
-
result
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# Filter list of known binary/problematic field names by service
|
|
130
|
-
BINARY_DATA_FILTERS = {
|
|
131
|
-
# ElevenLabs fields that contain binary audio data
|
|
132
|
-
elevenlabs: %w[
|
|
133
|
-
full_audio
|
|
134
|
-
audio
|
|
135
|
-
audio_data
|
|
136
|
-
raw_audio
|
|
137
|
-
audio_base64
|
|
138
|
-
voice_sample
|
|
139
|
-
audio_url
|
|
140
|
-
],
|
|
141
|
-
# OpenAI fields that might contain binary data
|
|
142
|
-
openai: %w[
|
|
143
|
-
file_content
|
|
144
|
-
audio_data
|
|
145
|
-
image_data
|
|
146
|
-
binary_content
|
|
147
|
-
]
|
|
148
|
-
}.freeze
|
|
149
|
-
|
|
150
|
-
private
|
|
151
|
-
|
|
152
|
-
# Get all filtered field names as a flat array
|
|
153
|
-
def filtered_field_names
|
|
154
|
-
@filtered_field_names ||= BINARY_DATA_FILTERS.values.flatten.map(&:downcase)
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
# Recursively sanitize payload to remove known problematic fields
|
|
158
|
-
def sanitize_payload_for_json(obj)
|
|
159
|
-
case obj
|
|
160
|
-
when Hash
|
|
161
|
-
obj.each_with_object({}) do |(key, value), sanitized|
|
|
162
|
-
key_str = key.to_s.downcase
|
|
163
|
-
|
|
164
|
-
# Skip if key matches any filtered field name
|
|
165
|
-
next if filtered_field_names.any? { |filter| key_str.include?(filter) }
|
|
166
|
-
|
|
167
|
-
sanitized[key] = sanitize_payload_for_json(value)
|
|
168
|
-
end
|
|
169
|
-
when Array
|
|
170
|
-
obj.map { |item| sanitize_payload_for_json(item) }
|
|
171
|
-
else
|
|
172
|
-
obj
|
|
173
|
-
end
|
|
174
|
-
rescue StandardError => e
|
|
175
|
-
log "⚠️ Warning: Error sanitizing payload: #{e.message}"
|
|
176
|
-
obj
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def log_feedback_info(feedback)
|
|
180
|
-
return if silent
|
|
181
|
-
|
|
182
|
-
# Log the appropriate identifier based on what was provided
|
|
183
|
-
if feedback[:llm_request_log_id]
|
|
184
|
-
puts "\n📝 CREATING FEEDBACK for LLM Request Log ID: #{feedback[:llm_request_log_id]}"
|
|
185
|
-
elsif feedback[:llm_provider_unique_id]
|
|
186
|
-
puts "\n📝 CREATING FEEDBACK for Provider Unique ID: #{feedback[:llm_provider_unique_id]}"
|
|
187
|
-
else
|
|
188
|
-
puts "\n📝 CREATING FEEDBACK"
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
puts "👍/👎 Like: #{feedback[:like]}"
|
|
192
|
-
|
|
193
|
-
if feedback[:explanation]
|
|
194
|
-
explanation = feedback[:explanation]
|
|
195
|
-
truncated = explanation.length > 100 ? "#{explanation[0..99]}..." : explanation
|
|
196
|
-
puts "💭 Explanation: #{truncated}"
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
puts "📤 Sending to: #{@api_endpoint}"
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def log_request_info(captured_data)
|
|
203
|
-
return if silent
|
|
204
|
-
|
|
205
|
-
puts "\n🎉 LOGGING OpenAI API Call #{@api_endpoint}"
|
|
206
|
-
puts captured_data
|
|
207
|
-
puts "📤 Sending to: #{@api_endpoint}"
|
|
208
|
-
end
|
|
209
|
-
end
|
|
210
|
-
end
|
|
211
|
-
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,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,119 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Coolhand
|
|
4
|
-
class Interceptor < Faraday::Middleware
|
|
5
|
-
ORIGINAL_METHOD_ALIAS = :coolhand_original_initialize
|
|
6
|
-
|
|
7
|
-
def self.patch!
|
|
8
|
-
return if @patched
|
|
9
|
-
|
|
10
|
-
@patched = true
|
|
11
|
-
Coolhand.log "📡 Monitoring outbound requests ..."
|
|
12
|
-
|
|
13
|
-
# Use prepend instead of alias_method to avoid conflicts with other gems
|
|
14
|
-
Faraday::Connection.prepend(Module.new do
|
|
15
|
-
def initialize(url = nil, options = nil, &block)
|
|
16
|
-
super
|
|
17
|
-
|
|
18
|
-
# Only add interceptor if it's not already present
|
|
19
|
-
use Coolhand::Interceptor unless @builder.handlers.any? { |h| h.klass == Coolhand::Interceptor }
|
|
20
|
-
end
|
|
21
|
-
end)
|
|
22
|
-
|
|
23
|
-
Coolhand.log "🔧 Setting up monitoring for Faraday ..."
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def self.unpatch!
|
|
27
|
-
# NOTE: With prepend, there's no clean way to unpatch
|
|
28
|
-
# We'll mark it as unpatched so it can be re-patched
|
|
29
|
-
@patched = false
|
|
30
|
-
Coolhand.log "🔌 Faraday monitoring disabled ..."
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def self.patched?
|
|
34
|
-
@patched
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def call(env)
|
|
38
|
-
return super unless llm_api_request?(env)
|
|
39
|
-
|
|
40
|
-
Coolhand.log "🎯 INTERCEPTING OpenAI call #{env.url}"
|
|
41
|
-
|
|
42
|
-
call_data = build_call_data(env)
|
|
43
|
-
buffer = override_on_data(env)
|
|
44
|
-
|
|
45
|
-
process_complete_callback(env, buffer, call_data)
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
private
|
|
49
|
-
|
|
50
|
-
def llm_api_request?(env)
|
|
51
|
-
Coolhand.configuration.intercept_addresses.any? do |address|
|
|
52
|
-
env.url.to_s.include?(address)
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def build_call_data(env)
|
|
57
|
-
{
|
|
58
|
-
id: SecureRandom.uuid,
|
|
59
|
-
timestamp: DateTime.now,
|
|
60
|
-
method: env.method,
|
|
61
|
-
url: env.url.to_s,
|
|
62
|
-
headers: sanitize_headers(env.request_headers),
|
|
63
|
-
request_body: parse_json(env.request_body),
|
|
64
|
-
response_body: nil,
|
|
65
|
-
response_headers: nil,
|
|
66
|
-
status_code: nil
|
|
67
|
-
}
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def override_on_data(env)
|
|
71
|
-
buffer = +""
|
|
72
|
-
original_on_data = env.request.on_data
|
|
73
|
-
env.request.on_data = proc do |chunk, overall_received_bytes|
|
|
74
|
-
buffer << chunk
|
|
75
|
-
|
|
76
|
-
original_on_data&.call(chunk, overall_received_bytes)
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
buffer
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def process_complete_callback(env, buffer, call_data)
|
|
83
|
-
@app.call(env).on_complete do |response_env|
|
|
84
|
-
if buffer.empty?
|
|
85
|
-
body = response_env.body
|
|
86
|
-
else
|
|
87
|
-
body = buffer
|
|
88
|
-
response_env.body = body
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
call_data[:response_body] = parse_json(body)
|
|
92
|
-
call_data[:response_headers] = sanitize_headers(response_env.request_headers)
|
|
93
|
-
call_data[:status_code] = response_env.status
|
|
94
|
-
|
|
95
|
-
Thread.new { Coolhand.logger_service.log_to_api(call_data) }
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def parse_json(string)
|
|
100
|
-
JSON.parse(string)
|
|
101
|
-
rescue JSON::ParserError, TypeError
|
|
102
|
-
string
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def sanitize_headers(headers)
|
|
106
|
-
sanitized = headers.transform_keys(&:to_s).dup
|
|
107
|
-
|
|
108
|
-
if sanitized["Authorization"]
|
|
109
|
-
sanitized["Authorization"] = sanitized["Authorization"].gsub(/Bearer .+/, "Bearer [REDACTED]")
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
%w[openai-api-key api-key].each do |key|
|
|
113
|
-
sanitized[key] = "[REDACTED]" if sanitized[key]
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
sanitized
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
end
|