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.
data/lib/coolhand.rb CHANGED
@@ -1,4 +1,111 @@
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
+ 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,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.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: 2025-12-17 00:00:00.000000000 Z
13
- dependencies: []
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'
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,28 @@ 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
46
+ - coolhand-ruby.gemspec
32
47
  - docs/anthropic.md
33
48
  - docs/elevenlabs.md
34
49
  - 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
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
45
64
  - sig/coolhand/ruby.rbs
46
65
  homepage: https://coolhandlabs.com/
47
66
  licenses:
@@ -50,9 +69,8 @@ metadata:
50
69
  allowed_push_host: https://rubygems.org
51
70
  homepage_uri: https://coolhandlabs.com/
52
71
  source_code_uri: https://github.com/Coolhand-Labs/coolhand-ruby
53
- changelog_uri: https://github.com/Coolhand-Labs/coolhand-ruby
72
+ changelog_uri: https://github.com/Coolhand-Labs/coolhand-ruby/blob/main/CHANGELOG.md
54
73
  rubygems_mfa_required: 'true'
55
- post_install_message:
56
74
  rdoc_options: []
57
75
  require_paths:
58
76
  - lib
@@ -67,8 +85,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
67
85
  - !ruby/object:Gem::Version
68
86
  version: '0'
69
87
  requirements: []
70
- rubygems_version: 3.3.26
71
- signing_key:
88
+ rubygems_version: 3.6.2
72
89
  specification_version: 4
73
90
  summary: Monitor and log LLM API calls from OpenAI, Anthropic, and other providers
74
91
  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