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.
data/lib/coolhand.rb CHANGED
@@ -1,4 +1,115 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Main entry point for the Coolhand gem
4
- require_relative "coolhand/ruby"
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
+ return unless configuration.enabled
49
+
50
+ configuration.validate!
51
+
52
+ NetHttpInterceptor.patch!
53
+
54
+ log "✅ Coolhand ready - will log inference calls on monitored URIs"
55
+ end
56
+
57
+ def capture
58
+ unless block_given?
59
+ log "❌ Coolhand Error: Method .capture requires block."
60
+ return
61
+ end
62
+
63
+ return yield unless configuration.enabled
64
+
65
+ patched = NetHttpInterceptor.patched?
66
+ NetHttpInterceptor.patch!
67
+ begin
68
+ yield
69
+ ensure
70
+ NetHttpInterceptor.unpatch! unless patched
71
+ end
72
+ end
73
+
74
+ def without_capture
75
+ previous = Thread.current[:coolhand_capture_override]
76
+ Thread.current[:coolhand_capture_override] = false
77
+ yield
78
+ ensure
79
+ Thread.current[:coolhand_capture_override] = previous
80
+ end
81
+
82
+ def with_capture
83
+ previous = Thread.current[:coolhand_capture_override]
84
+ Thread.current[:coolhand_capture_override] = true
85
+ yield
86
+ ensure
87
+ Thread.current[:coolhand_capture_override] = previous
88
+ end
89
+
90
+ # A simple logger that respects the 'silent' configuration option.
91
+ def log(message)
92
+ return if configuration.silent
93
+
94
+ puts "COOLHAND: #{message}"
95
+ end
96
+
97
+ # Creates a new FeedbackService instance
98
+ def feedback_service
99
+ FeedbackService.new
100
+ end
101
+
102
+ # Creates a new LoggerService instance
103
+ def logger_service
104
+ LoggerService.new
105
+ end
106
+
107
+ def required_field?(value)
108
+ return false if value.nil?
109
+ return false if value.respond_to?(:empty?) && value.empty?
110
+ return false if value.to_s.strip.empty?
111
+
112
+ true
113
+ end
114
+ end
115
+ end
metadata CHANGED
@@ -1,16 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coolhand
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.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: 2025-12-17 00:00:00.000000000 Z
13
- dependencies: []
11
+ date: 2026-06-22 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'
14
27
  description: Automatically intercept and log LLM requests from Ruby applications.
15
28
  Supports OpenAI, official Anthropic gem, ruby-anthropic gem, and other Faraday-based
16
29
  libraries. Features dual interceptor architecture, streaming support, thread-safe
@@ -26,22 +39,27 @@ files:
26
39
  - ".rubocop.yml"
27
40
  - ".simplecov"
28
41
  - CHANGELOG.md
42
+ - CLAUDE.md
29
43
  - LICENSE
30
44
  - README.md
31
45
  - Rakefile
32
46
  - docs/anthropic.md
33
47
  - docs/elevenlabs.md
34
48
  - lib/coolhand.rb
35
- - lib/coolhand/ruby.rb
36
- - lib/coolhand/ruby/anthropic_interceptor.rb
37
- - lib/coolhand/ruby/api_service.rb
38
- - lib/coolhand/ruby/base_interceptor.rb
39
- - lib/coolhand/ruby/collector.rb
40
- - lib/coolhand/ruby/configuration.rb
41
- - lib/coolhand/ruby/faraday_interceptor.rb
42
- - lib/coolhand/ruby/feedback_service.rb
43
- - lib/coolhand/ruby/logger_service.rb
44
- - lib/coolhand/ruby/version.rb
49
+ - lib/coolhand/api_service.rb
50
+ - lib/coolhand/base_interceptor.rb
51
+ - lib/coolhand/collector.rb
52
+ - lib/coolhand/configuration.rb
53
+ - lib/coolhand/default_exclude_api_patterns.yml
54
+ - lib/coolhand/default_intercept_addresses.yml
55
+ - lib/coolhand/feedback_service.rb
56
+ - lib/coolhand/logger_service.rb
57
+ - lib/coolhand/net_http_interceptor.rb
58
+ - lib/coolhand/open_ai/batch_result_processor.rb
59
+ - lib/coolhand/open_ai/webhook_validator.rb
60
+ - lib/coolhand/version.rb
61
+ - lib/coolhand/vertex/batch_result_processor.rb
62
+ - lib/coolhand/webhook_interceptor.rb
45
63
  - sig/coolhand/ruby.rbs
46
64
  homepage: https://coolhandlabs.com/
47
65
  licenses:
@@ -50,9 +68,8 @@ metadata:
50
68
  allowed_push_host: https://rubygems.org
51
69
  homepage_uri: https://coolhandlabs.com/
52
70
  source_code_uri: https://github.com/Coolhand-Labs/coolhand-ruby
53
- changelog_uri: https://github.com/Coolhand-Labs/coolhand-ruby
71
+ changelog_uri: https://github.com/Coolhand-Labs/coolhand-ruby/blob/main/CHANGELOG.md
54
72
  rubygems_mfa_required: 'true'
55
- post_install_message:
56
73
  rdoc_options: []
57
74
  require_paths:
58
75
  - lib
@@ -67,8 +84,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
67
84
  - !ruby/object:Gem::Version
68
85
  version: '0'
69
86
  requirements: []
70
- rubygems_version: 3.3.26
71
- signing_key:
87
+ rubygems_version: 3.6.2
72
88
  specification_version: 4
73
89
  summary: Monitor and log LLM API calls from OpenAI, Anthropic, and other providers
74
90
  to Coolhand analytics.
@@ -1,300 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- begin
4
- require "anthropic"
5
- rescue LoadError
6
- # Anthropic gem not available - interceptor will be a no-op
7
- end
8
- require "securerandom"
9
- require "json"
10
- require "ostruct"
11
- require_relative "base_interceptor"
12
-
13
- module Coolhand
14
- module Ruby
15
- module AnthropicInterceptor
16
- module_function
17
-
18
- def patch!
19
- return if @patched
20
- return unless defined?(Anthropic)
21
-
22
- # Check if both anthropic gems are installed
23
- if both_gems_installed?
24
- # Always show this warning, regardless of silent mode
25
- warn_message = "⚠️ Warning: Both 'anthropic' and 'ruby-anthropic' gems are installed. " \
26
- "Coolhand will only monitor ruby-anthropic (Faraday-based) requests. " \
27
- "Official anthropic gem monitoring has been disabled."
28
- puts "COOLHAND: #{warn_message}"
29
-
30
- # Mark as patched since ruby-anthropic will be handled by FaradayInterceptor
31
- @patched = true
32
- return
33
- end
34
-
35
- # Check if we're using the official anthropic gem
36
- # The official gem has Anthropic::Internal, ruby-anthropic doesn't
37
- if defined?(Anthropic::Internal)
38
- # Patch the BaseClient request method for official anthropic gem
39
- require "anthropic/internal/transport/base_client"
40
- ::Anthropic::Internal::Transport::BaseClient.prepend(RequestInterceptor)
41
- else
42
- # ruby-anthropic uses Faraday, so the FaradayInterceptor already handles it
43
- Coolhand.log "✅ ruby-anthropic detected, using Faraday interceptor"
44
- @patched = true
45
- return
46
- end
47
-
48
- # Patch MessageStream to capture completion data
49
- patch_message_stream!
50
-
51
- @patched = true
52
- Coolhand.log "✅ Anthropic interceptor patched"
53
- end
54
-
55
- def unpatch!
56
- # NOTE: Ruby doesn't have a clean way to unpatch prepended modules
57
- # This is mainly for testing - in production, patching is permanent
58
- @patched = false
59
- Coolhand.log "⚠️ Anthropic interceptor unpatch requested (not fully implemented)"
60
- end
61
-
62
- def patched?
63
- @patched ||= false
64
- end
65
-
66
- def both_gems_installed?
67
- # Check if both gems are installed by looking at loaded specs
68
- anthropic_gem = Gem.loaded_specs["anthropic"]
69
- ruby_anthropic_gem = Gem.loaded_specs["ruby-anthropic"]
70
-
71
- anthropic_gem && ruby_anthropic_gem
72
- end
73
-
74
- def patch_message_stream!
75
- # Try to load MessageStream class if available
76
- begin
77
- require "anthropic/helpers/streaming/message_stream"
78
- rescue LoadError
79
- # MessageStream not available in this version of anthropic gem
80
- return
81
- end
82
-
83
- # Only proceed if the constant is now defined
84
- return unless defined?(Anthropic::Streaming::MessageStream)
85
-
86
- # Prepend our patch module
87
- ::Anthropic::Streaming::MessageStream.prepend(MessageStreamInterceptor)
88
- end
89
-
90
- module RequestInterceptor
91
- def request(method:, path:, body: nil, headers: {}, **options)
92
- # Generate request ID for correlation
93
- request_id = SecureRandom.hex(16)
94
- start_time = Time.now
95
-
96
- # Store request ID in thread-local storage for application access
97
- Thread.current[:coolhand_current_request_id] = request_id
98
-
99
- # Temporarily disable Faraday interception for this thread to prevent double logging
100
- Thread.current[:coolhand_disable_faraday] = true
101
-
102
- # Extract request metadata
103
- full_url = "#{@base_url}#{path}"
104
-
105
- # Capture all request headers including those added by the client
106
- request_headers = BaseInterceptor.clean_request_headers(headers.dup)
107
- request_body = body
108
-
109
- # Detect if this is a streaming request
110
- is_streaming = streaming_request?(body, headers)
111
-
112
- # Call the original request method
113
- begin
114
- response = super
115
- end_time = Time.now
116
- duration_ms = ((end_time - start_time) * 1000).round(2)
117
-
118
- # For streaming responses, store request metadata for later logging
119
- if is_streaming
120
- Thread.current[:coolhand_streaming_request] = {
121
- request_id: request_id,
122
- method: method,
123
- url: full_url,
124
- request_headers: request_headers,
125
- request_body: request_body,
126
- start_time: start_time,
127
- end_time: end_time,
128
- duration_ms: duration_ms,
129
- is_streaming: is_streaming
130
- }
131
- else
132
- # Extract response data
133
- response_data = BaseInterceptor.extract_response_data(response)
134
-
135
- # Send complete request/response data in single API call
136
- BaseInterceptor.send_complete_request_log(
137
- request_id: request_id,
138
- method: method,
139
- url: full_url,
140
- request_headers: request_headers,
141
- request_body: request_body,
142
- response_headers: extract_response_headers(response),
143
- response_body: response_data,
144
- status_code: nil,
145
- start_time: start_time,
146
- end_time: end_time,
147
- duration_ms: duration_ms,
148
- is_streaming: is_streaming
149
- )
150
- end
151
-
152
- response
153
- rescue StandardError => e
154
- end_time = Time.now
155
- duration_ms = ((end_time - start_time) * 1000).round(2)
156
-
157
- # Send error response in single API call
158
- BaseInterceptor.send_complete_request_log(
159
- request_id: request_id,
160
- method: method,
161
- url: full_url,
162
- request_headers: request_headers,
163
- request_body: request_body,
164
- response_headers: {},
165
- response_body: {
166
- error: {
167
- message: e.message,
168
- class: e.class.name
169
- }
170
- },
171
- status_code: nil,
172
- start_time: start_time,
173
- end_time: end_time,
174
- duration_ms: duration_ms,
175
- is_streaming: is_streaming
176
- )
177
- raise
178
- ensure
179
- # Always re-enable Faraday interception for this thread
180
- Thread.current[:coolhand_disable_faraday] = false
181
- end
182
- end
183
-
184
- # Public method for applications to log final streaming response
185
- def self.log_streaming_completion(final_response_body)
186
- streaming_request = Thread.current[:coolhand_streaming_request]
187
- return unless streaming_request
188
-
189
- begin
190
- send_complete_request_log(
191
- request_id: streaming_request[:request_id],
192
- method: streaming_request[:method],
193
- url: streaming_request[:url],
194
- request_headers: streaming_request[:request_headers],
195
- request_body: streaming_request[:request_body],
196
- response_headers: {},
197
- response_body: final_response_body,
198
- start_time: streaming_request[:start_time],
199
- end_time: streaming_request[:end_time],
200
- duration_ms: streaming_request[:duration_ms],
201
- is_streaming: streaming_request[:is_streaming]
202
- )
203
-
204
- # Clear the thread-local data
205
- Thread.current[:coolhand_streaming_request] = nil
206
- rescue StandardError => e
207
- Coolhand.log "❌ Error logging streaming completion: #{e.message}"
208
- end
209
- end
210
-
211
- def self.send_complete_request_log(request_id:, method:, url:, request_headers:, request_body:,
212
- response_headers:, response_body:, start_time:, end_time:, duration_ms:, is_streaming:)
213
- BaseInterceptor.send_complete_request_log(
214
- request_id: request_id,
215
- method: method,
216
- url: url,
217
- request_headers: request_headers,
218
- request_body: request_body,
219
- response_headers: response_headers,
220
- response_body: response_body,
221
- status_code: nil,
222
- start_time: start_time,
223
- end_time: end_time,
224
- duration_ms: duration_ms,
225
- is_streaming: is_streaming
226
- )
227
- end
228
-
229
- private
230
-
231
- def streaming_request?(body, headers)
232
- # Check if stream parameter is set in request body
233
- return true if body.is_a?(Hash) && body[:stream] == true
234
-
235
- # Check Accept header for Server-Sent Events
236
- accept_header = headers["Accept"] || headers["accept"]
237
- return true if accept_header&.include?("text/event-stream")
238
-
239
- false
240
- end
241
-
242
- def extract_response_headers(response)
243
- # Try to extract headers if the response object exposes them
244
- if response.respond_to?(:headers)
245
- BaseInterceptor.clean_response_headers(response.headers)
246
- elsif response.respond_to?(:response_headers)
247
- BaseInterceptor.clean_response_headers(response.response_headers)
248
- else
249
- # Anthropic gem doesn't expose response headers directly
250
- # Return empty hash to indicate no headers are available
251
- {}
252
- end
253
- end
254
- end
255
-
256
- module MessageStreamInterceptor
257
- def accumulated_message
258
- # Call the original method to get the accumulated message
259
- message = super
260
-
261
- # Log the completion data if we have streaming request metadata
262
- streaming_request = Thread.current[:coolhand_streaming_request]
263
- log_streaming_completion(message, streaming_request) if streaming_request
264
-
265
- message
266
- end
267
-
268
- private
269
-
270
- def log_streaming_completion(message, streaming_request)
271
- # Convert message to hash for logging (preserving natural format)
272
- response_body = extract_response_data(message)
273
-
274
- # Send the completion log
275
- BaseInterceptor.send_complete_request_log(
276
- request_id: streaming_request[:request_id],
277
- method: streaming_request[:method],
278
- url: streaming_request[:url],
279
- request_headers: streaming_request[:request_headers],
280
- request_body: streaming_request[:request_body],
281
- response_headers: {},
282
- response_body: response_body,
283
- status_code: nil,
284
- start_time: streaming_request[:start_time],
285
- end_time: streaming_request[:end_time],
286
- duration_ms: streaming_request[:duration_ms],
287
- is_streaming: streaming_request[:is_streaming]
288
- )
289
-
290
- # Clear the thread-local data
291
- Thread.current[:coolhand_streaming_request] = nil
292
- end
293
-
294
- def extract_response_data(message)
295
- BaseInterceptor.extract_response_data(message)
296
- end
297
- end
298
- end
299
- end
300
- end